Такие вопросы возникают у разработчиков, начинающих использовать библиотеку Redux, и даже у тех, кто ей активно пользуется.
Мы в BENOVATE за 5 лет разработки на React опробовали на практике различные подходы к построению архитектуры таких приложений. В статье рассмотрим возможные критерии для выбора места хранения данных в приложении.
А может быть совсем без Redux? Да, если вы можете обойтись без него. На эту тему можете ознакомиться со статьей от одного из создателей библиотеки Дэна Абрамова. Если разработчик понимает, что без Redux не обойтись, то, можно выделить несколько критериев для выбора хранилища данных:
- Продолжительность жизни данных
- Частота использования
- Возможность отслеживания изменений в state
Продолжительность жизни данных
Можно выделить 2 категории:
- Часто изменяющиеся данные.
- Редко изменяющиеся данные. Такие данные редко изменяются во время непосредственной работы пользователя с приложением или между сеансами работы с приложением.
Часто изменяющиеся данные
К этой категории относятся, например, параметры фильтрации, сортировки и постраничной навигации компонента, реализующего работу со списком объектов, или флаг, отвечающий за отображение отдельных UI-элементов в приложении, например, выпадающий список или модальное окно (при условии, что оно не привязано к пользовательским настройкам). Сюда же можно отнести и данные заполняемой формы, пока они не отправлены на сервер.
Такие данные лучше хранить в state компонента, т.к. они захламляют глобальное хранилище и усложняют работу с ними: надо писать actions, reducers, инициализировать state и вовремя его очищать.
import React from 'react';import { connect } from 'react-redux';import { toggleModal } from './actions/simpleAction'import logo from './logo.svg';import './App.css';import Modal from './elements/modal';const App = ({ openModal, toggleModal, }) => { return ( <div className="App"> <header className="App-header"> <img src={logo} className="App-logo" alt="logo" /> </header> <main className="Main"> <button onClick={() => toggleModal(true)}>{'Open Modal'}</button> </main> <Modal isOpen={openModal} onClose={() => toggleModal(false)} /> </div> );}const mapStateToProps = (state) => { return { openModal: state.simple.openModal, }}const mapDispatchToProps = { toggleModal }export default connect( mapStateToProps, mapDispatchToProps)(App)// src/constants/simpleConstants.jsexport const simpleConstants = { TOGGLE_MODAL: 'SIMPLE_TOGGLE_MODAL',};// src/actions/simpleAction.jsimport { simpleConstants} from "../constants/simpleConstants";export const toggleModal = (open) => ( { type: simpleConstants.TOGGLE_MODAL, payload: open, });// src/reducers/simple/simpleReducer.jsimport { simpleConstants } from "../../constants/simpleConstants";const initialState = { openModal: false,};export function simpleReducer(state = initialState, action) { switch (action.type) { case simpleConstants.TOGGLE_MODAL: return { ...state, openModal: action.payload, }; default: return state; }}
import React, {useState} from 'react';import logo from './logo.svg';import './App.css';import Modal from './elements/modal';const App = () => { const [openModal, setOpenModal] = useState(false); return ( <div className="App"> <header className="App-header"> <img src={logo} className="App-logo" alt="logo" /> </header> <main className="Main"> <button onClick={() => setOpenModal(true)}>{'Open Modal'}</button> </main> <Modal isOpen={openModal} onClose={() => setOpenModal(false)} /> </div> );}export default App;
Редко изменяющиеся данные
Это данные, которые обычно не изменяются между обновлениями страницы или между отдельными визитами на страницу пользователем.
Поскольку хранилище Redux при обновлении страницы создается заново, то данные этого типа должны храниться где-то еще: в базе данных на сервере или в локальном хранилище в браузере.
Это могут быть данные справочников или пользовательские настройки. Например, при разработке приложения, использующего пользовательские настройки, после аутентификации пользователя мы сохраняем эти настройки в Redux store, что позволяет использовать их компонентам приложения без обращения к серверу.
При этом стоит помнить, что некоторые данные могут изменяться на сервере без участия пользователя, и требуется предусмотреть, как на это будет реагировать ваше приложение.
// App.jsimport React from 'react';import './App.css';import Header from './elements/header';import ProfileEditForm from './elements/profileeditform';const App = () => { return ( <div className="App"> <Header /> <main className="Main"> <ProfileEditForm /> </main> </div> );}export default App;// src/elements/header.jsimport React from "react";import logo from "../logo.svg";import Menu from "./menu";export default () => ( <header className="App-header"> <img src={logo} className="App-logo" alt="logo" /> <Menu /> </header>)// src/elements/menu.jsimport React, {useEffect, useState} from "react";import { getUserInfo } from '../api';const Menu = () => { const [userInfo, setUserInfo] = useState({}); useEffect(() => { getUserInfo().then(data => { setUserInfo(data); }); }, []); return ( <> <span>{userInfo.userName}</span> <nav> <ul> <li>Item 1</li> <li>Item 2</li> <li>Item 3</li> <li>Item 4</li> </ul> </nav> </> )}export default Menu;// src/elements/profileeditform.jsimport React, {useEffect, useState} from "react";import {getUserInfo} from "../api";const ProfileEditForm = () => { const [state, setState] = useState({ isLoading: true, userName: null, }) const setName = (e) => { const userName = e.target.value; setState(state => ({ ...state, userName, })); } useEffect(() => { getUserInfo().then(data => { setState(state => ({ ...state, isLoading: false, userName: data.userName, })); }); }, []); if (state.isLoading) { return null; } return ( <form> <input type="text" value={state.userName} onChange={setName} /> <button>{'Save'}</button> </form> )}export default ProfileEditForm;
// App.jsimport React, {useEffect} from 'react';import {connect} from "react-redux";import './App.css';import Header from './elements/header';import ProfileEditForm from './elements/profileeditform';import {loadUserInfo} from "./actions/userAction";const App = ({ loadUserInfo }) => { useEffect(() => { loadUserInfo() }, []) return ( <div className="App"> <Header /> <main className="Main"> <ProfileEditForm /> </main> </div> );}export default connect( null, { loadUserInfo },)(App);// src/elements/header.jsimport React from "react";import logo from "../logo.svg";import Menu from "./menu";export default () => ( <header className="App-header"> <img src={logo} className="App-logo" alt="logo" /> <Menu /> </header>)// src/elements/menu.jsimport React from "react";import { connect } from "react-redux";const Menu = ({userName}) => ( <> <span>{userName}</span> <nav> <ul> <li>Item 1</li> <li>Item 2</li> <li>Item 3</li> <li>Item 4</li> </ul> </nav> </>)const mapStateToProps = (state) => { return { userName: state.userInfo.userName, }}export default connect( mapStateToProps,)(Menu);// src/elements/profileeditform.jsimport React from "react";import { changeUserName } from '../actions/userAction'import {connect} from "react-redux";const ProfileEditForm = ({userName, changeUserName}) => { const handleChange = (e) => { changeUserName(e.target.value); }; return ( <form> <input type="text" value={userName} onChange={handleChange} /> <button>{'Save'}</button> </form> )}const mapStateToProps = (state) => { return { userName: state.userInfo.userName, }}const mapDispatchToProps = { changeUserName }export default connect( mapStateToProps, mapDispatchToProps,)(ProfileEditForm);// src/constants/userConstants.jsexport const userConstants = { SET_USER_INFO: 'USER_SET_USER_INFO', SET_USER_NAME: 'USER_SET_USER_NAME', UNDO: 'USER_UNDO', REDO: 'USER_REDO',};// src/actions/userAction.jsimport { userConstants } from "../constants/userConstants";import { getUserInfo } from "../api/index";export const changeUserName = (userName) => ( { type: userConstants.SET_USER_NAME, payload: userName, });export const setUserInfo = (data) => ( { type: userConstants.SET_USER_INFO, payload: data, })export const loadUserInfo = () => async (dispatch) => { const result = await getUserInfo(); dispatch(setUserInfo(result));}// src/reducers/user/userReducer.jsimport { userConstants } from "../../constants/userConstants";const initialState = { userName: null,};export function userReducer(state = initialState, action) { switch (action.type) { case userConstants.SET_USER_INFO: return { ...state, ...action.payload, }; case userConstants.SET_USER_NAME: return { ...state, userName: action.payload, }; default: return state; }}
Частота использования
Второй критерий сколько компонентов в React-приложении должно иметь доступ к одному и тому же state. Чем больше компонентов используют одни и те же данные в state, тем больше пользы от использования Redux store.
Если вы понимаете, что для определенного компонента или небольшой части вашего приложения state изолирован, то лучше использовать React state отдельного компонента или HOC-компонент.
Глубина передачи state
В приложениях без Redux данные React state должны храниться в самом верхнем (в дереве) компоненте, дочерним компонентам которого потребуется доступ к этим данным, в предположении, что мы избегаем хранения одинаковых данных в разных местах.
Иногда данные из state родительского компонента требуются большому количеству дочерних компонентов на разных уровнях вложенности, что приводит к сильному зацеплению компонентов и появлению в них бесполезного кода, который накладно редактировать каждый раз, когда вы обнаружите, что дочернему компоненту требуется доступ к новым данным state. В таких случаях разумнее сохранить state в Redux и извлекать нужные данные из хранилища в соответствующих компонентах.
Если же необходимо передать данные state дочерним компонентам на один-два уровня вложенности, то можно это сделать и без Redux.
//App.jsimport React from 'react';import './App.css';import Header from './elements/header';import MainContent from './elements/maincontent';const App = ({userName}) => { return ( <div className="App"> <Header userName={userName} /> <main className="Main"> <MainContent /> </main> </div> );}export default App;// ./elements/header.jsimport React from "react";import logo from "../logo.svg";import Menu from "./menu";export default ({ userName }) => ( <header className="App-header"> <img src={logo} className="App-logo" alt="logo" /> <Menu userName={userName} /> </header>)// ./elements/menu.jsimport React from "react";export default ({userName}) => ( <> <span>{userName}</span> <nav> <ul> <li>Item 1</li> <li>Item 2</li> <li>Item 3</li> <li>Item 4</li> </ul> </nav> </>)
// App.jsimport React from 'react';import './App.css';import Header from './elements/header';import MainContent from './elements/maincontent';const App = () => { return ( <div className="App"> <Header /> <main className="Main"> <MainContent /> </main> </div> );}export default App;//./elements/header.jsimport React from "react";import logo from "../logo.svg";import Menu from "./menu";export default () => ( <header className="App-header"> <img src={logo} className="App-logo" alt="logo" /> <Menu /> </header>)//./elements/menu.jsimport React from "react";import { connect } from "react-redux";const Menu = ({userName}) => ( <> <span>{userName}</span> <nav> <ul> <li>Item 1</li> <li>Item 2</li> <li>Item 3</li> <li>Item 4</li> </ul> </nav> </>)const mapStateToProps = (state) => { return { userName: state.userInfo.userName, }}export default connect( mapStateToProps,)(Menu)
Несвязанные компоненты, оперирующие одинаковыми данными в state
Случаются ситуации, когда нескольким, относительно не связанным компонентам, необходим доступ к одному и тому же state. Например, в приложении требуется создать форму редактирования профиля пользователя и header, в котором тоже требуется отображать данные пользователя.
Конечно, можно дойти до крайности, когда вы создадите супер-компонент верхнего уровня, который хранит данные о профиле пользователя и, во-первых, передает их в компонент header и его дочерним компонентам, и, во-вторых, передает их глубже по дереву, к компоненту редактирования профиля. При этом в форму редактирования профиля потребуется также передать callback, который будет вызываться при изменении данных пользователя.
Во-первых, такой подход, скорее всего, приведет к сильному зацеплению компонентов, появлению ненужных данных и ненужного кода в промежуточных компонентах, на актуализацию и поддержку которого будет тратиться время.
Во-вторых, без дополнительных изменений кода, скорее всего, вы получите компоненты, которые сами не используют переданные в них данные, но будут рендериться каждый раз, когда эти данные будут обновляться, что приведет к снижению скорости работы приложения.
Можно сделать проще: сохраняем данные профиля пользователя в Redux store, и позволяем компоненту контейнера header и компоненту редактирования профиля получать и изменять данные в Redux store.
// App.jsimport React, {useState} from 'react';import './App.css';import Header from './elements/header';import ProfileEditForm from './elements/profileeditform';const App = ({user}) => { const [userName, setUserName] = useState(user.user_name); return ( <div className="App"> <Header userName={userName} /> <main className="Main"> <ProfileEditForm onChangeName={setUserName} userName={userName} /> </main> </div> );}export default App;// ./elements/header.jsimport React from "react";import logo from "../logo.svg";import Menu from "./menu";export default ({ userName }) => ( <header className="App-header"> <img src={logo} className="App-logo" alt="logo" /> <Menu userName={userName} /> </header>)// ./elements/menu.jsimport React from "react";const Menu = ({userName}) => ( <> <span>{userName}</span> <nav> <ul> <li>Item 1</li> <li>Item 2</li> <li>Item 3</li> <li>Item 4</li> </ul> </nav> </>)export default Menu;// ./elements/profileeditform.jsimport React from "react";export default ({userName, onChangeName}) => { const handleChange = (e) => { onChangeName(e.target.value); }; return ( <form> <input type="text" value={userName} onChange={handleChange} /> <button>{'Save'}</button> </form> )}
// App.jsimport React from 'react';import './App.css';import Header from './elements/header';import ProfileEditForm from './elements/profileeditform';const App = () => { return ( <div className="App"> <Header /> <main className="Main"> <ProfileEditForm /> </main> </div> );}export default App;//./elements/header.jsimport React from "react";import logo from "../logo.svg";import Menu from "./menu";export default () => ( <header className="App-header"> <img src={logo} className="App-logo" alt="logo" /> <Menu /> </header>)//./elements/menu.jsimport React from "react";import { connect } from "react-redux";const Menu = ({userName}) => ( <> <span>{userName}</span> <nav> <ul> <li>Item 1</li> <li>Item 2</li> <li>Item 3</li> <li>Item 4</li> </ul> </nav> </>)const mapStateToProps = (state) => { return { userName: state.userInfo.userName, }}export default connect( mapStateToProps,)(Menu)//./elements/profileeditformimport React from "react";import { changeUserName } from '../actions/userAction'import {connect} from "react-redux";const ProfileEditForm = ({userName, changeUserName}) => { const handleChange = (e) => { changeUserName(e.target.value); }; return ( <form> <input type="text" value={userName} onChange={handleChange} /> <button>{'Save'}</button> </form> )}const mapStateToProps = (state) => { return { userName: state.userInfo.userName, }}const mapDispatchToProps = { changeUserName }export default connect( mapStateToProps, mapDispatchToProps,)(ProfileEditForm)
Возможность отслеживания изменений в state
Другой случай: вам требуется реализовать возможность отменять/повторять пользовательские операции в приложении или вы просто хотите логировать изменения state.
Такая необходимость возникла у нас при разработке конструктора учебных пособий, с помощью которого пользователь может добавлять и настраивать блоки с текстом, изображением и видео на страницу пособия, а также может выполнять операции Undo/Redo.
В подобных случаях Redux отличное решение, т.к. каждый созданный action является атомарным изменением state. Redux упрощает все эти задачи, сосредотачивая их в одном месте Redux store.
// App.jsimport React from 'react';import './App.css';import Header from './elements/header';import ProfileEditForm from './elements/profileeditform';const App = () => { return ( <div className="App"> <Header /> <main className="Main"> <ProfileEditForm /> </main> </div> );}export default App;// './elements/profileeditform.js'import React from "react";import { changeUserName, undo, redo } from '../actions/userAction'import {connect} from "react-redux";const ProfileEditForm = ({ userName, changeUserName, undo, redo, hasPast, hasFuture }) => { const handleChange = (e) => { changeUserName(e.target.value); }; return ( <> <form> <input type="text" value={userName} onChange={handleChange} /> <button>{'Save'}</button> </form> <div> <button onClick={undo} disabled={!hasPast}>{'Undo'}</button> <button onClick={redo} disabled={!hasFuture}>{'Redo'}</button> </div> </> )}const mapStateToProps = (state) => { return { hasPast: !!state.userInfo.past.length, hasFuture: !!state.userInfo.future.length, userName: state.userInfo.present.userName, }}const mapDispatchToProps = { changeUserName, undo, redo }export default connect( mapStateToProps, mapDispatchToProps,)(ProfileEditForm)// src/constants/userConstants.jsexport const userConstants = { SET_USER_NAME: 'USER_SET_USER_NAME', UNDO: 'USER_UNDO', REDO: 'USER_REDO',};// src/actions/userAction.jsimport { userConstants } from "../constants/userConstants";export const changeUserName = (userName) => ( { type: userConstants.SET_USER_NAME, payload: userName, });export const undo = () => ( { type: userConstants.UNDO, });export const redo = () => ( { type: userConstants.REDO, });// src/reducers/user/undoableUserReducer.jsimport {userConstants} from "../../constants/userConstants";export function undoable(reducer) { const initialState = { past: [], present: reducer(undefined, {}), future: [], }; return function userReducer(state = initialState, action) { const {past, present, future} = state; switch (action.type) { case userConstants.UNDO: const previous = past[past.length - 1] const newPast = past.slice(0, past.length - 1) return { past: newPast, present: previous, future: [present, ...future] } case userConstants.REDO: const next = future[0] const newFuture = future.slice(1) return { past: [...past, present], present: next, future: newFuture } default: const newPresent = reducer(present, action) if (present === newPresent) { return state } return { past: [...past, present], present: newPresent, future: [] } } }}// src/reducers/user/userReducer.jsimport { undoable } from "./undoableUserReducer";import { userConstants } from "../../constants/userConstants";const initialState = { userName: 'username',};function reducer(state = initialState, action) { switch (action.type) { case userConstants.SET_USER_NAME: return { ...state, userName: action.payload, }; default: return state; }}export const userReducer = undoable(reducer);
Резюмируя
Рассмотреть вариант хранения данных в Redux store стоит в следующих случаях:
- Если эти данные редко изменяются;
- Если одни и те же данные используются в нескольких (больше 2-3) связанных компонентах или в несвязанных компонентах;
- Если требуется отслеживать изменения данных.
Во всех остальных случаях лучше использовать React state.
P.S. Большое спасибо mamdaxx111 за помощь в подготовке статьи!