Несмотря на всю мощь реакта, с ростом приложения, довольно быстро, появляются некоторые неудобства. Одно из самых раздражающих - подъем состояния наверх через колбеки, которые нужно прокидывать в самый низ, с того самого верхнего уровня. Прокидывать приходится не только колбеки, но и любые данные. Получается что множество промежуточных компонентов выступают в качестве прокси, то есть пропускают сквозь себя данные, которыми не пользуются. Второе - рендеринг и логика мешается в одном месте, быстро раздувая компоненты и усложняя их понимание. Сюда добавляются неконтролируемые побочные эффекты вперемешку с обновлением данных.
Для решения, в том числе этих проблем, разработчики фейсбука придумали архитектуру Flux
Flux архитектура вводит ряд новых понятий, таких как:
- Stores - хранилища, место в которое загружаются данные и в котором они обновляются. Хотя во Flux хранилища мутабельные, взаимодействовать с внешним миром внутри них нельзя. Никаких AJAX запросов, взаимодействия с DOM и подобных вещей. Менять данные напрямую тоже не получится, только посредством действий. Как видите, во Flux архитектуре менеджмент состояния приложения был вынесен наружу.
- Actions - действия с помощью Dispatcher передаются в хранилища, которые на основе типа действия и данных пришедших с ним, сами себя обновляют.
- Dispatcher - раскидывает действия по хранилищам.
Flux архитектура позволила разгрузить React и ввела недостающие абстракции. В свое время, появилось множество различных реализаций этой архитектуры. Одна из них была официальная и десяток неофициальных.
С тех пор утекло много воды и мир шагнул вперед. В 2015 году, Dan Abramov, создал библиотеку под названием Redux заимствовав идеи из Flux и функционального языка Elm.
Сам по себе Redux, очень простая библиотека, предназначенная исключительно для менеджемента состояния. Она хоть и была разработана для использования в реакте, от него не зависит и может использоваться с чем угодно. Для ее связи с реактом понадобится библиотека react-redux
, с помощью которой производится необходимая интеграция. В итоге получается структура крайне напоминающая Flux архитектуру, но со значительными упрощениями и улучшениями.
Кроме тех преимуществ, которые дает Flux, Redux привносит еще кое что:
- Time traveling. возможность путешествовать по изменению состояния назад и вперед. Очень полезно при отладке, всегда можно отмотать (это не фигуральное выражение, а действительность) назад.
- Удобная отладка и визуализация. Посредством Middlewares, Redux расширяется инструментарием, который предоставляет крайне удобные средства для отладки и визуализации происходящих внутри процессов. С ними мы познакомимся в ближайшее время чтобы сразу начать использовать всю мощь Redux.
- Благодаря стандартизации работы с состоянием, которую привнес Redux, стало возможным автоматизировать практически все аспекты работы в React. Работа с формами, роутинг, асинхронность, история и многое другое.
В этом курсе, мы пройдем по основным возможностям редакса и интегрируем в создаваемое приложение, по ходу курса, множество разных библиотек. Мы не сможем копнуть в них глубоко, так как объем документации каждой библиотеки тянет на целую книгу, но разберем базовые варианты использования.
Основные темы:
- Redux
- Middlewares
- Containers
- Actions (Async)
- Redux & React
Библиотеки:
- reselect
- redux-actions
- redux-forms
- redux-thunk
По ходу курса мы создадим приложение для работы с задачами и в конце интегрируем его с бекендом.
Redux - Predictable state container for JavaScript apps
Перед тем как начать, скажу сразу, что сам Redux прост до безобразия. Первые несколько уроков мы набъем руку по работе с ним, а уже после начнем интегрироваться с React, так как сама интеграция значительно сложнее.
import { createStore } from 'redux';
const reducer = (state = 0, action) => {
switch (action.type) {
case 'INCREMENT':
return state + 1;
case 'DECREMENT':
return state - 1;
default:
return state;
}
};
const store = createStore(reducer);
- Импортируется функция
createStore
, которая создает контейнер. - Далее определяется, так называемый reducer . Функция, которая принимает на вход
state
иaction
. На выходе из функции возвращается новыйstate
. Именно из-за сходства работы этой функции с тем как работаетreduce
, она имеет название reducer . - Редьюсер передается в функцию
createStore
и на выходе мы имеем готовый к использованию контейнер.
Ключевая идея в Redux, в том что данные никогда не мутируются. Вместо этого на основе старого состояния, создается новое. Как это делать, мы разбирали на протяжении всей профессии. Теперь использование:
// Функция `subscribe` является частью реализации паттерна Observer.
// Каждый ее вызов, добавляет в список наблюдателей новую функцию.
// Затем, как только меняются данные в хранилище, вызываются, по очереди, все наблюдатели.
store.subscribe(() =>
console.log(store.getState()),
);
const incrementAction = { type: 'INCREMENT' };
store.dispatch(incrementAction);
// => 1
store.dispatch(incrementAction);
// => 2
const decrementAction = { type: 'DECREMENT' };
store.dispatch(decrementAction);
// => 1
- Единственный способ произвести изменения состояния в хранилище, передать Action в функцию
dispatch
. Action это обычный js объект, в котором присутствует минимум одно свойство -type
. Никаких ограничений на содержимое этого свойства не накладывается, главное чтобы внутри контейнера был подходящий ему обработчик. Обратите внимание на то, что если был передан Action который не описан в Switch, reducer вернет само состояние. Отсюда, кстати, следует, что состояние должно быть инициализировано в определении функции.
Посмотрим еще один пример уже включающий в себя составные данные:
const reducer = (state = [], action) => { // инициализация состояния
switch (action.type) {
case 'USER_ADD': {
const user = action.payload.user; // данные
return [...state, user]; // Immutability
}
case 'USER_REMOVE': {
const id = action.payload.id; // данные
return state.filter(u => u.id !== id); // Immutability
}
default:
return state;
}
};
const user = /* ... */;
const addUser = { type: 'USER_ADD', payload: { user } };
store.dispatch(addUser);
Несмотря на то что ключ payload
не обязательный и можно все данные складывать прямо в сам Action, я крайне рекомендую так не делать. Мешать в одном объекте статически заданные ключи с динамическими плохая идея. Кроме того в будущем, мы будем использовать библиотеки, которые требуют именно такого способа работы.
Three Principles
Подведем итог. Что главное в redux:
- Single source of truth - используя редакс, мы работаем только с одним контейнером на приложение. Это одно из ключевых отличий от Flux архитектуры. Все состояние в одном месте.
- State is read-only - Данные меняются только косвенно и используя функциональный стиль.
- Changes are made with pure functions - внутри хранилища можно использовать только чистые функции. Тут правила даже строже чем во Flux, так как не позволяется использовать даже
Date.now()
и ему подобные функции. Которые хотя и не обладают побочными эффектами, но все же являются недетерминированными. Все подобные вызовы должны делаться до вызоваdispatch
(подробнее об этом далее).
Начальное состояние
Я говорил про то что начальное состояние задается в определении редьюсера:
const reducer = (state = 0, action) { /* ... */ }
Но часто этого недостаточно. Чаще данные приходят из бекенда и их нужно прогрузить в контейнер перед началом работы. Для этого случая в Redux есть особый путь:
const store = createStore(reducer, initState);
// @@redux/INIT
Redux посылает специальный Action, который нельзя перехватывать. Если редьюсер реализован правильно и содержит default
секцию в switch
, то контейнер заполнится данными из initState
.
JS: Redux (React) → Middlewares
Middlewares относятся к продвинутым техникам использования Redux. В данном уроке мы посмотрим на них не с точки зрения написания, а исключительно с точки зрения использования. Нам они потребуются для подключения различных библиотек буквально с первого момента использования совместно с React.
Если вы знакомы с мидллварами в Express.js, то они не станут для вас сюрпризом. Общий принцип работы таков. Мидлвары встраиваются в хранилище при его создании. Затем, во время диспатчинга, данные проходят через них и только затем попадают в редьюсер.
Такая организация библиотеки, позволяет ее крайне легко расширять новой функциональностью, по принципу паттерна Decorator.
Типичные примеры использования включают:
- Логирование
- Оповещение об ошибках
- Работа с асинхронным API
- Роутинг
Посмотрим как их подключить:
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
const store = createStore(
reducer,
/* preloadedState, */
applyMiddleware(thunk)
)
thunk
это мидлвара, но перед тем как передать ее в функцию createStore
, нужно применить к ней функцию applyMiddleware
. Также обратите внимание на то, что мидлвару мы передаем вторым параметром, хотя в предыдущем уроке вторым параметром шел initState
. Объясняется это просто, функция createStore
проверяет тип второго параметра и в зависимости от этого понимает что перед ней. В общем случае она принимает три параметра: редьюсер, начальный стейт и мидлвары.
В случае если мидлвар несколько, придется воспользоваться еще одной функцией:
import { createStore, applyMiddleware, compose } from 'redux';
import thunk from 'redux-thunk';
import logger from 'redux-logger';
const store = createStore(
reducer,
/* preloadedState, */
compose(
applyMiddleware(thunk),
applyMiddleware(logger)
),
)
В такой ситуации, в store
передается результат функции compose
. Последняя, в свою очередь принимает на вход мидлвары.
Теперь мы подобрались к главному. Для редакса написано специальное браузерное расширение Redux DevTools. Установите его в свой браузер.
Ниже код подключения этого экстеншена к хранилищу:
const reduxDevtools = window.__REDUX_DEVTOOLS_EXTENSION__;
const store = createStore(
reducer,
/* preloadedState, */
reduxDevtools && reduxDevtools(),
);
Обратите внимание на то что он не требует использования функции applyMiddleware
.
В будущих уроках вам не придется подключать его руками. Мы уже сделали это за вас. Все что нужно - установить расширение и не забывать туда смотреть. Это ваш главный помощник в отладке на протяжении всего курса.
Дополнительные материалы
JS: Redux (React) → Reducers
Все что хранится в Store, мы называем состоянием, но не все состояния одинаково полезны. Вот какую классификацию вводит документация Redux:
- Domain data - данные приложения, которые нужно отображать, использовать и модифицировать. Например список пользователей загруженный с сервера.
- App state - Данные определяющие поведение приложения. Например текущий открытый URL.
- UI state - Данные определяющие то как выглядит UI. Например вывод списка в плиточном виде.
Так как Store представляет собой ядро приложения, данные внутри него должны описываться в терминах domain data и app state, но не как дерево компонентов UI. Например такой способ формирования состояния state.leftPane.todoList.todos
- плохая идея. Крайне редко дерево компонентов отражается напрямую на структуру состояния и это нормально. Представление зависит от данных, а не данные от представления.
Типичная структура состояния выглядит так:
{
domainData1 : {}, // todos
domainData2 : {}, // comments
appState1 : {},
appState2 : {},
uiState1 : {}
uiState2 : {},
}
Подробнее про работу с состоянием UI будет рассказано в соответствующем уроке
Как уже говорилось в курсе React, структура состояния должна напоминать базу данных. Все максимально плоско и нормализованно.
{
todos: {
1: { id: 1, name: 'why?' },
3: { id: 3, name: 'who?' },
},
comments: {
23: { id: 23, todoId: 3, text: 'great!' },
},
}
С такой структурой, крайне легко писать реакцию на действия, обновлять данные, добавлять новые и удалять старые. Вложенность небольшая, все просто. Но появляется другая проблема (появляется она в любом случае). С ростом количества сущностей, редьюсер становится очень тяжелым. Огромный кусок кода, который делает все.
Redux имеет встроенный механизм, позволяющий создавать множественные редьюсеры и комбинировать их друг с другом. Работает это так. Для каждого свойства верхнего уровня пишется свой собственный редьюсер, а затем они с помощью функции combineReducers
объединяются в корневой ( root
) редьюсер, который уже используется для создания контейнера.
import { combineReducers, createStore } from 'redux';
const todos = (state = {}, action) => {
// state is todos part
};
const comments = (state = {}, action) => {
// state is comments
};
const rootReducer = combineReducers({ todos, comments });
const store = createStore(rootReducer);
Обратите внимание на то что если редьюсер именовать так же как и свойство, то можно написать так: { todos, comments }
. В каждый редьюсер приходит state
, но это не все состояние контейнера, а только та часть, которая лежит в соответствующем свойстве. Не забудьте про это.
Редьюсеры могут быть даже вложенными и для этого не нужны никакие специальные средства, обычные функции принимающие на вход данные и возвращающие новые данные.
С таким подходом появляется одна особенность, которая, по началу, может испугать. Так как каждый редьюсер имеет доступ только к своей части состояния, действия порождающие изменения сразу в нескольких местах, будут повторяться в разных редьюсерах:
const todos = (state = {}, action) => {
switch (action.type) {
case 'TODO_REMOVE':
// ...
}
};
const comments = (state = {}, action) => {
switch (action.type) {
// При удалении ToDo нужно удалить все его комментарии
case 'TODO_REMOVE':
// ...
}
};
То есть правильный подход состоит в том чтобы повторять case
часть в нужных редьюсерах, а не в том чтобы пытаться получить недостающие части состояния.
Дополнительные материалы
JS: Redux (React) → Redux Actions
Типичный Action выглядит так:
{
type: 'TODO_ADD',
payload: {
/* data */
}
}
В даже небольших приложениях на js, действий десятки, а то и сотни. В итоге появляется множество одинакового кода. К тому же есть проблема, если ошибиться с именем, хоть при формировании действия или в редьюсере, то система ничего не скажет, придется отлаживать такую ситуацию руками.
По этой причине, появились библиотеки-хелперы, помогающие сократить код. Самой популярной является redux-actions. Она включает в себя два аспекта:
- Определение действий
- Определение хранилища
Начнем с действий:
import { createAction } from 'redux-actions';
import store from './store';
const addUser = createAction('USER_ADD');
const updateUser = createAction('USER_UPDATE');
store.dispatch(addUser(/* ... */));
Функция createAction
формирует новую функцию, которая при вызове возвращает действие (объект { type: ... }
). Эта функция принимает на вход данные, которые попадают в свойство payload
.
Подразумевается что сгенерированные функции экспортируются наружу и используются при вызовах store.dispatch()
.
Теперь посмотрим редьюсеры:
import { handleActions } from 'redux-actions';
import * as actions from './actions';
const users = handleActions({
[actions.addUser](state, { payload: { user } }) {
return { ...state, [user.id]: user };
},
}, {});
export default combineReducers({
users,
});
На первый взгляд может показаться что кода прибавилось, но на самом деле его столько же. Но появились и плюшки:
- Каждый обработчик теперь, отдельная функция, а значит не будет проблем с определением одинаковых констант как в случае
switch
. - Не нужно описывать
default
поведение, когда возвращается самstate
- Стало невозможным диспатчить неправильное действие, так как каждая функция обработчик формируется на основании функций генерирующих действия.
В остальном все остается по прежнему. Эта библиотека не делает ничего кардинально нового, но позволяет сократить код и упростить отладку.
JS: Redux (React) → React Redux
Переходим к самой ожидаемой части - интеграции редакса с реактом. Сразу скажу что дело это не простое и не всегда понятное. Поэтому действовать будем по следующей схеме. Я покажу пошагово как скрестить ежа с ужом, без погружения в детали, а в дальнейших уроках мы разберемся что да как.
Начнем с того, что самостоятельно интегрировать редакс не надо, будет больно. Для этой задачи уже написан и, поддерживается командой реакта, пакет react-redux
. Он, в свою очередь, вводит новые понятия, которых нет ни в Redux ни в React. К ним относятся:
- Provider
- Container
Provider
Начнем с провайдера. Первым делом нужно обернуть корневой React компонент в провайдер:
import React from 'react'
import { render } from 'react-dom'
import { Provider } from 'react-redux' // импорт компонента!
import TasksContainer from './containers/Tasks';
import store from './store';
render(
<Provider store={store}>
<TasksContainer />
</Provider>,
document.getElementById('container'),
);
Провайдер исключительно утилитарная сущность. Во-первых это обычный компонент реакта. Во-вторых он хранит внутри себя Store и пробрасывает его нижележащим компонентам. Делается это посредством механизма реакта Context, но об этом можно не знать и не думать.
Container
В связке React и Redux компоненты начинают делиться на два новых типа. Один тип, по сути, это чистая функция работающая только через props, другой тип - так называемые, контейнеры. Они в свою очередь получают данные из Store и распространяют их дальше. Про контейнеры будет отдельный урок.
import { connect } from 'react-redux';
import Tasks from '../components/Tasks.jsx';
const mapStateToProps = state => {
const props = {
tasks: state.tasks,
}
return props;
};
export default connect(mapStateToProps)(Tasks);
Компонент-контейнер не совсем реален. То есть он существует, но мы его не создаем руками. Этот компонент не содержит виртуального дома и его единственная цель, это взять данные из Store и отдать их в обычный React компонент. Создается он вот таким вызовом: connect(mapStateToProps)(Tasks)
. Как видите это функция, которая сначала принимает на вход функцию mapStateToProps
и возвращает функцию, в которую передается уже компонент реакта.
Самое главное в этой схеме - mapStateToProps
. Эта функция принимает на вход состояние из Store и должна возвратить объект, свойства которого станут props
в подключаемом компоненте (в данном случае <Tasks>
). В тривиальном случае мы всегда можем реализовывать эту функцию так state => state
. То есть берем и отдаем в компонент все состояние. Но делать так не стоит по многим причинам, начиная от полной просадки производительности, заканчивая тем что появляется сильная зависимость от структуры состояния и лишние данные там где их не ждут. Более того, всю предварительную обработку данных, подготовленных для вывода стоит делать именно здесь. В идеале в компоненты должны попадать уже готовые к выводу данные.
С точки зрения реакта, схема получается следующая:
А вот в результирующем HTML, контейнер следов не оставляет.
И самое главное, компонент-контейнер сам в дерево не встроится, его нужно импортировать и самостоятельно вставлять в реакт там где нужно. Повторю первый пример из этого урока:
import React from 'react'
import { render } from 'react-dom'
import { Provider } from 'react-redux' // импорт компонента!
import TasksContainer from './containers/Tasks';
import store from './store';
render(
<Provider store={store}>
<TasksContainer />
</Provider>,
document.getElementById('container'),
);
dispatch
Неразобранным остался вопрос работы с Actions. Любой компонент реакта обернутый в контейнерный компонент, в пропсы получает функцию dispatch
. Эта функция работает точь в точь как и store.dispatch
. Ей нужно передать Action, что в свою очередь запустит цепочку вызовов до перерисовки.
import { addTask } from '../actions';
export default class Tasks extends React.Component {
addTask = (e) => {
e.preventDefault();
this.props.dispatch(addTask({ text: this.props.newTaskText }));
};
render() {
return <div>{/* logic */}</div>;
}
}
Как мы увидим позже, ту же самую задачу можно делать проще.
Файловая структура
Имея такое количество сущностей, возникает закономерный вопрос о том как их раскладывать в файловой системе. Общепринятой является следующая схема:
actions/index.js
components/App.jsx
containers/App.js
reducers/index.js
index.jsx
Если кода становится много, то вы всегда можете разделить его по модулям в соответствующих папках.
JS: Redux (React) → Containers
Еще одна полезная функция контейнера - оборачивание Actions. Она позволяет обходиться без функции dispatch
. Общий принцип работы такой, в файл с контейнером импортируются Actions и передаются вторым параметром в функцию connect
.
import { connect } from 'react-redux';
import Component from '../components/TasksList.jsx';
import * as actionCreators from '../actions';
export default connect(
mapStateToProps,
actionCreators,
)(Component);
Обратите внимание на то что импортировать нужно используя *
. Вообще это не единственный способ передачи, об остальных вариантах можно прочитать в официальной документации.
Теперь использовать Actions становится гораздо проще:
export default class TasksList extends React.Component {
removeTask = id => (e) => {
e.preventDefault();
this.props.removeTask({ id });
}
render() {
// ...
}
}
Как видите ничего не надо импортировать, все есть в props
. Технически, Actions оборачиваются в другие функции таким образом, что интерфейс работы остается прежним.
Components
Поговорим подробнее о типах компонентов:
- Presentational
- Container
Компоненты-представления | Компоненты-контейнеры | |
---|---|---|
Назначение | Как выглядит (разметка, стили) | Как работает (загрузка данных, обновление состояния) |
Знают о Redux | Нет | Да |
Читают данные | Читают данные из props | Подписываются на Redux-состояние |
Изменяют данные | Вызывают колбеки из props | Отправляют Redux-действия |
Написаны | Руками | Обычно генерируются React Redux |
Не существует одного единственно верного способа определить, когда использовать презентационные компоненты, а когда компоненты-контейнеры. Понимание придет с опытом, а пока следуйте следующему совету: используйте по-максимуму презентационные компоненты пока это не сложнее, чем с контейнерными компонентами. Когда становится сложнее — вводите компоненты-контейнеры.
Дополнительные материалы
JS: Redux (React) → Reselect
Еще одна задача которую решают контейнеры - оптимизация. Они автоматически отслеживают то что возвращается из mapStateToProps
и если ничего не изменилось с предыдущего рендеринга, то перерисовки не будет. По сути, контейнер ведет себя как PureComponent
. Из этого есть несколько следствий:
- Нужно стараться передавать как можно меньше данных (но не меньше чем нужно).
- Нужно избегать изменений в
mapStateToProps
Но не забывайте что сама функция mapStateToProps
выполняется всегда.
Последнее рассмотрим подробнее. Напомню наш код из практики:
const mapStateToProps = ({ tasks }) => {
const props = {
tasks: Object.values(tasks),
};
return props;
};
На первый взгляд в коде все нормально, но, на самом деле, Object.values
создает каждый раз новый объект. Даже если tasks
остались прежними. А значит ни о какой эффективности не может быть и речи. Очевидным решением будет перенести эту логику внутрь компонента, но тогда теряется одно из главных преимуществ маппинга. Компоненты завязываются на структуру состояния и выполняют работу по подготовке данных, которая, кстати, начнет дублироваться.
Для решения этой задачи создан пакет reselect
. Он позволяет создавать специальные функции “селекторы”, которые выполняют мемоизацию результата. То есть если данные не поменялись, то и результат работы функции будет тем же самым значением или объектом в случае составных данных.
Отмечу что reselect
не связан ни с Redux ни с React. Нет никакого слоя интеграции. Селекторы сами по себе и их легко использовать в контейнерах без конфигурации.
Перед тем как создать первый селектор, нужно написать функцию, которая принимает на вход состояние и возвращает нужный срез данных. В нашем случае используется функция getTasks
. Затем, с помощью функции createSelector
создается селектор. В пример выше в функцию createSelector
передается наша исходная функция, и вторая функция, которая производит фильтрацию данных, полученных первой функцией.
Посмотрите на пример ниже:
const getTasks = state => Object.values(state.tasks);
const mapStateToProps = (state) => {
const props = {
tasks: getTasks(state),
};
return props;
};
В коде выше сразу две ошибки. Во-первых функция извлекающая getTask
не селектор, хотя без нее селектор не сделаешь. То есть никакой мемоизации не будет. Во-вторых, ни в коем случае нельзя делать преобразования данных на этом этапе. Именно эту функцию используют селекторы чтобы узнать, а изменились ли данные. Если функция содержит обработку, то данные всегда будут новые и смысл селектора пропадает. Хотя, технически, в коде он останется.
import { createSelector } from 'reselect';
const getTasks = state => state.tasks;
const tasksSelector = createSelector(
getTasks,
tasks => Object.values(tasks),
);
const mapStateToProps = (state) => {
const props = {
tasks: tasksSelector(state),
};
return props;
};
Хорошая новость в том что селекторы можно соединять:
import { createSelector } from 'reselect';
const getTasks = state => state.tasks;
const tasksSelector = createSelector(
getTasks,
tasks => Object.values(tasks),
);
const publishedTasksSelector = createSelector(
tasksSelector,
tasks => tasks.filter(t => t.state === 'published'),
);
const percentOfFinishedTasksSelector = createSelector(
publishedTasksSelector,
(tasks, publishedTasks) => (publishedTasks.length / tasks.length) * 100,
)
const mapStateToProps = (state) => {
const props = {
tasks: tasksSelector(state),
publishedTasks: publishedTasksSelector(state),
percentOfFinishedTasks: percentOfFinishedTasksSelector(state),
};
return props;
};
Как это работает:
- Селектор вызывает все переданные ему селекторы (которые в свою очередь делают тоже самое и так до самого дна) и собирает результаты их вызовов в массив
results
- Селектор вызывает последнюю переданную функцию как
f(...results)
. Другими словами, количество аргументов в последней переданной функции селектору, равно количеству селекторов переданных перед этой функцией. - То что получилось и есть результат который вернет селектор (а заодно сохранит внутри)
Хотя по началу такая комбинаторика может пугать, в реальности селекторы очень простая вещь. Рекомендую их использовать часто. Кроме самого факта мемоизации, они позволяют переиспользовать выборки в разных компонентах. В файловой системе рекомендуется размещать их по пути selectors/index.js
.
JS: Redux (React) → UI State
UI State отличается от остальных видов состояния тем, что относится только к UI и не влияет на остальные части приложения. Например всплывающая подсказка при наведении мышкой на элемент. В таких ситуациях бывает полезно хранить состояние не в Redux, а внутри компонентов отвечающих за соответствующий вывод на экран. Преимущества очень простые, не надо создавать действий, не надо задействовать весь механизм диспетчеризации. К тому же перерисовка коснется только небольшой части виртуального дома (скорее всего).
Практика показывает, что довольно часто нельзя провести четкую грань между app state и ui state. Начнем с того что отображение на экране зависит вообще от всего. Например в todo приложениях, завершенный todo, как правило, отображается зачеркнутым. В данном случае зачеркнутость определяется тем какое значение в свойстве state
у соответствующего todo. Текущий выбранный элемент может быть исключительно элементом UI, а может влиять на поведение программы, например определенных кнопок, позволяющих выполнять действий в стиле “удалить все выбранное”.
- Открытое модальное окно
- Подсвеченный текущий элемент
- Элемент в режиме редактирования
- Отфильтрованный список
- Отображение в три колонки
Вот лишь некоторые примеры, которые можно отнести либо к одной либо к другой части состояния.
Так где хранить UI State?
А вот что говорят по этому поводу разработчики React:
The rule of thumb is: do whatever is less awkward.
Другими словами, делайте так как наименее тяжело и затратно. По умолчанию исходите из предположения что все хранится в Redux. В своей практике я припомню лишь несколько случаев, когда использовался локальный стейт. В основном такое бывает у сторонних компонентов или в самописных виджетах.
Но чего не стоит делать однозначно, так это примешивать app state и ui state в domain data. Ниже приводится пример правильного разделения:
const state = {
tasks: {
1: { /* ... */ },
},
tasksUIState: {
1: { state: 'editing' },
}
};
JS: Redux (React) → Redux Forms
Еще одно место, напрашивающееся на автоматизацию - формы. Работа с формами в React настоящая головная боль. Каждый новый элемент требует синхронизации с состоянием и написанием дополнительного кода на всех уровнях.
import React from 'react';
class Form extends React.Component {
updateNewTaskText = e =>
this.props.updateNewTaskText({ text: e.target.value });
render() {
const { newTaskText } = this.props;
return <form action="" className="form-inline">
<div className="form-group mx-sm-3">
<input type="text" required
value={newTaskText} onChange={this.updateNewTaskText} />
</div>
</form>;
}
}
Этого можно избежать подключив библиотеку наподобие redux-form
. Документация этого пакета поистинне огромна. Множество вариантов использования и способов кастомизации. Чтобы не сойти с ума, в уроке мы рассмотрим только базовые возможности этой библиотеки.
Как и в случае с самим Redux, подключить ReduxForm целая история. Начнем по порядку:
- Автоматизация синхронизации с контейнером подразумевает наличие специального редьюсера:
import { reducer as formReducer } from 'redux-form';
export default combineReducers({
form: formReducer,
/* ... */
});
- Теперь под каждую форму нужно выделять отдельный компонент и оборачивать его в компонент ReduxForm.
import { reduxForm } from 'redux-form';
class NewTaskForm extends React.Component {
// ...
}
export default reduxForm({
form: 'newTask',
})(NewTaskForm);
Обратите внимание на свойство form
, оно задает имя ключа, под которым данные текущей формы будут храниться в Redux.
3. Вместо использования стандартных компонентов реакта для элементов формы, ReduxForm поставляется со своими механизмами. Потребность вполне понятная, иначе не сделать автоматическую синхронизацию.
import { Field } from 'redux-form';
// ...
render() {
return <form className="form-inline">
<div className="form-group mx-3">
<Field name="text" required component="input" type="text" />
</div>
<button type="submit" className="btn btn-primary btn-sm">Add</button>
</form>;
}
Эта часть ReduxForm очень сильно кастомизируется. За подробностями прошу в официальную документацию.
4. Остался последний шаг - отправка формы и работа с ее данными.
class NewTaskForm extends React.Component {
addTask = (values) => {
this.props.addTask({ task: values });
}
render() {
return <form onSubmit={this.props.handleSubmit(this.addTask)}>
{/* ... */}
<button type="submit" className="btn btn-primary btn-sm">Add</button>
</form>;
}
}
ReduxForm прокидывает в формы целую россыпь различных свойств. Главное из них - функция handleSubmit
. Ее необходимо вызвать на onSubmit
формы, передав туда свой собственный обработчик. После отправки формы, в этот обработчик попадут все значения из формы в виде объекта, где свойство это имя элемента формы.
Теперь расширение и изменение любой формы станет настоящим праздником. Достаточно изменить саму форму и волшебным образом в обработчик начнут приходить новые данные.
А дальше начинаются нюансы.
Как очистить форму после отправки?
В обработчике отправки можно вызывать функцию this.props.reset()
и форма будет сброшена в первоначальный вид.
Как задать параметры по умолчанию?
Достаточно передать в компонент свойство initialValues
.
Дополнительные материалы
JS: Redux (React) → Async Actions
В отличие от синхронных запросов, которые выполняются здесь и сейчас, асинхронные растянуты во времени. Каждый асинхронный запрос можно представить конечным автоматом с тремя состояниями “something requested”, “answer received” или “request failed”. Почему это важно? Как минимум нам важно знать когда запрос был выполнен, чтобы оповестить пользователя и произвести необходимые изменения. Но также важно знать когда запрос начался.
Предположим что пользователь заполнил форму создания новой задачи и нажал “создать”, а потом быстро нажал “создать” еще раз, до того, как запрос успел выполнится. Такая ситуация нередко встречается и с обычными формами, без js. Она приводит к тому, что на сервере создается две одинаковые сущности, либо выскакивают ошибки связанные с валидацией. Правильное решение, в подобной ситуации, связано с необходимостью менять интерфейс так, чтобы повторная отправка стала невозможной. Как правило, все сводится к блокированию кнопки отправки с крутящимся спинером. Соответственно после окончания запроса кнопку необходимо разблокировать, или, если того требует UI, вообще убрать форму. Тоже самое нужно делать не только в случае успеха, но и в случае провала, иначе может получится ситуация что пользовательский запрос провалился, а кнопка осталась заблокирована навсегда (до перезагрузки страницы).
С точки зрения нашего React-Redux приложения, автомат будет состоять из состояния в контейнере и трех событий:
- TASK_UPDATE_REQUEST
- TASK_UPDATE_SUCCESS
- TASK_UPDATE_FAILURE
Именование в стиле request, success и failure рекомендация самого Redux. Желательно всегда придерживаться именно ее в случаях когда состояния три. Большинство запросов укладываются именно в эту схему.
export const updateTaskRequest = createAction('TASK_UPDATE_REQUEST');
export const updateTaskSuccess = createAction('TASK_UPDATE_SUCCESS');
export const updateTaskFailure = createAction('TASK_UPDATE_FAILURE');
Actions описанные выше, по сути синхронны. А где тогда выполняется сам запрос?
Для этого вводится понятие Async Actions. И если в React для работы с асинхронными вызовами ничего дополнительно делать не нужно, то Redux, из коробки, это не умеет. Наиболее простой способ начать выполнять запросы на сервер - подключить библиотеку redux-thunk
. Она представляет из себя мидлвару, которую нужно не забыть подключить:
import thunk from 'redux-thunk';
const store = createStore(
reducers,
/* ... */,
applyMiddleware(thunk),
);
На этом интеграция заканчивается.
Следующим шагом создаются сами действия:
;
export const updateTask = (id, values) => async (dispatch) => {
dispatch(updateTaskRequest());
try {
const response = await axios.post(routes.taskUrl(id), { task: values });
dispatch(updateTaskSuccess({ task: response.data }));
} catch (e) {
console.log(e)
dispatch(updateTaskFailure());
}
};
В отличие от синхронных действий, асинхронное - функция, даже две функции. Внешняя функция принимает те параметры, которые нам нужны, а вот внутренняя, асинхронная, принимает на вход функцию dispatch
. Кстати эту внутреннюю функцию вызывать не придется, вся грязная работа делается автоматически за счет проброса действий через контейнер. Работа этого действия, для нашей ситуации, описывается так:
- Уведомляем Redux о начале внешнего запроса
- Выполняем внешний запрос
- Если запрос выполнился удачно, уведомляем Redux и передаем туда полученные данные (если есть)
- Если запрос выполнился неудачно, уведомляем Redux.
Этот паттерн встречается в реальных приложениях крайне часто.
Посмотрим как в коде реакта вызывается обработчик дергающий наш Action:
class EditTaskForm extends React.Component {
updateTask = (values) => {
this.props.updateTask(this.props.task.id, values);
}
render() {
const disabled = this.props.taskCreatingState === 'requested';
return <form action="" className="form-inline" onSubmit={this.props.handleSubmit(this.updateTask)}>
<div className="form-group mx-3">
<Field name="text" required component="input" type="text" />
</div>
<button type="submit" disabled={disabled} className="btn btn-primary btn-sm">Update</button>
</form>;
}
}
export default reduxForm({
form: 'editTask',
})(EditTaskForm);
Из кода выше видно что действие вызывается как обычно.
redux-thunk
всего лишь, один из многих способов работать с асинхронными действиями в Redux. Существуют и другие пакеты, дающие больший контроль и больший уровень автоматизации. Но, как правило, они сложнее в понимании.