Доброго времени суток, друзья!
Предлагаю вашему вниманию простое приложение — список задач. Что в нем особенного, спросите вы. Дело в том, что я попытался реализовать одну и ту же «тудушку» с использованием четырех разных подходов к управлению состоянием в React-приложениях: useState, useContext + useReducer, Redux Toolkit и Recoil.
Начнем с того, что такое состояние приложения, и почему так важен выбор правильного инструмента для работы с ним.
Состояние — это собирательное понятие для любой информации, имеющей отношение к приложению. Это могут быть как данные, используемые в приложении, такие как тот же список задач или список пользователей, так и состояние как таковое, например, состояние загрузки или состояние формы.
Условно, состояние можно разделить на локальное и глобальное. Под локальным состоянием, обычно, понимается состояние отдельно взятого компонента, например, состояние формы, как правило, является локальным состоянием соответствующего компонента. В свою очередь, глобальное состояние правильнее именовать распределенным или совместно используемым, подразумевая под этим то, что такое состояние используется более чем одним компонентом. Условность рассматриваемой градации выражается в том, что локальное состояние вполне может использоваться несколькими компонентами (например, состояние, определенное с помощью useState(), может в виде пропов передаваться дочерним компонентам), а глобальное состояние не обязательно используется всеми компонентами приложения (например, в Redux, где имеется одно хранилище для состояния всего приложения, обычно, создается отдельный срез (slice) состояния для каждой части UI, точнее, для логики управления этой частью).
Важность выбора правильного инструмента для управления состоянием приложения обусловлена теми проблемами, которые возникают при несоответствии инструмента размерам приложения или сложности реализуемой в нем логики. Мы убедимся в этом в процессе разработки списка задач.
Я не буду вдаваться в подробности работы каждого инструмента, а ограничусь общим описанием и ссылками на соответствующие материалы. Для прототипирования UI будет использоваться react-bootstrap.
Код на GitHub
Песочница на CodeSandbox
Создаем проект с помощью Create React App:
yarn create react-app state-management
# или
npm init react-app state-management
# или
npx create-react-app state-management
Устанавливаем зависимости:
yarn add bootstrap react-bootstrap nanoid
# или
npm i bootstrap react-bootstrap nanoid
- bootstrap, react-bootstrap — стили
- nanoid — утилита для генерации уникального ID
В src создаем директорию «use-state» для первого варианта тудушки.
useState()
Шпаргалка по хукам
Хук «useState()» предназначен для управления локальным состоянием компонента. Он возвращает массив с двумя элементами: текущим значением состояния и сеттером — функцией для обновления этого значения. Сигнатура данного хука:
const [state, setState] = useState(initialValue)
- state — текущее значение состояния
- setState — сеттер
- initialValue — начальное или дефолтное значение
Одним из преимуществ деструктуризации массива, в отличие от деструктуризации объекта, является возможность использования произвольных названий переменных. По соглашению, название сеттера должно начинаться с «set» + название первого элемента с большой буквы ([count, setCount], [text, setText] и т.п.).
Пока ограничимся четырьмя базовыми операциями: добавление, переключение (выполнение), обновление и удаление задачи, но усложним себе жизнь тем, что наше начальное состояние будет иметь форму нормализованных данных (это позволит как следует попрактиковаться в иммутабельном обновлении).
Структура проекта:
|--use-state
|--components
|--index.js
|--TodoForm.js
|--TodoList.js
|--TodoListItem.js
|--App.js
Думаю, тут все понятно.
В App.js мы с помощью useState() определяем начальное состояние приложения, импортируем и рендерим компоненты приложения, передавая им состояние и сеттер в виде пропов:
// хук
import { useState } from 'react'
// компоненты
import { TodoForm, TodoList } from './components'
// стили
import { Container } from 'react-bootstrap'
// начальное состояние
// изучите его как следует, чтобы понимать логику обновления
const initialState = {
todos: {
ids: ['1', '2', '3', '4'],
entities: {
1: {
id: '1',
text: 'Eat',
completed: true
},
2: {
id: '2',
text: 'Code',
completed: true
},
3: {
id: '3',
text: 'Sleep',
completed: false
},
4: {
id: '4',
text: 'Repeat',
completed: false
}
}
}
}
export default function App() {
const [state, setState] = useState(initialState)
const { length } = state.todos.ids
return (
<Container style={{ maxWidth: '480px' }} className='text-center'>
<h1 className='mt-2'>useState</h1>
<TodoForm setState={setState} />
{length ? <TodoList state={state} setState={setState} /> : null}
</Container>
)
}
В TodoForm.js мы реализуем добавление новой задачи в список:
// хук
import { useState } from 'react'
// утилита для генерации ID
import { nanoid } from 'nanoid'
// стили
import { Container, Form, Button } from 'react-bootstrap'
// функция принимает сеттер
export const TodoForm = ({ setState }) => {
const [text, setText] = useState('')
const updateText = ({ target: { value } }) => {
setText(value)
}
const addTodo = (e) => {
e.preventDefault()
const trimmed = text.trim()
if (trimmed) {
const id = nanoid(5)
const newTodo = { id, text, completed: false }
// обратите внимание, как нам приходится обновлять состояние
setState((state) => ({
...state,
todos: {
...state.todos,
ids: state.todos.ids.concat(id),
entities: {
...state.todos.entities,
[id]: newTodo
}
}
}))
setText('')
}
}
return (
<Container className='mt-4'>
<h4>Form</h4>
<Form className='d-flex' onSubmit={addTodo}>
<Form.Control
type='text'
placeholder='Enter text...'
value={text}
onChange={updateText}
/>
<Button variant='primary' type='submit'>
Add
</Button>
</Form>
</Container>
)
}
В TodoList.js мы просто рендерим список элементов:
// компонент
import { TodoListItem } from './TodoListItem'
// стили
import { Container, ListGroup } from 'react-bootstrap'
// функция принимает состояние и сеттер только для того,
// чтобы передать их потомкам
// обратите внимание, как мы передаем отдельную задачу
export const TodoList = ({ state, setState }) => (
<Container className='mt-2'>
<h4>List</h4>
<ListGroup>
{state.todos.ids.map((id) => (
<TodoListItem
key={id}
todo={state.todos.entities[id]}
setState={setState}
/>
))}
</ListGroup>
</Container>
)
Наконец, в TodoListItem.js происходит самое интересное — здесь мы реализуем оставшиеся операции: переключение, обновление и удаление задачи:
// стили
import { ListGroup, Form, Button } from 'react-bootstrap'
// функция принимает задачу и сеттер
export const TodoListItem = ({ todo, setState }) => {
const { id, text, completed } = todo
// переключение задачи
const toggleTodo = () => {
setState((state) => {
// небольшая оптимизация
const { todos } = state
return {
...state,
todos: {
...todos,
entities: {
...todos.entities,
[id]: {
...todos.entities[id],
completed: !todos.entities[id].completed
}
}
}
}
})
}
// обновление задачи
const updateTodo = ({ target: { value } }) => {
const trimmed = value.trim()
if (trimmed) {
setState((state) => {
const { todos } = state
return {
...state,
todos: {
...todos,
entities: {
...todos.entities,
[id]: {
...todos.entities[id],
text: trimmed
}
}
}
}
})
}
}
// удаление задачи
const deleteTodo = () => {
setState((state) => {
const { todos } = state
const newIds = todos.ids.filter((_id) => _id !== id)
const newTodos = newIds.reduce((obj, id) => {
if (todos.entities[id]) return { ...obj, [id]: todos.entities[id] }
else return obj
}, {})
return {
...state,
todos: {
...todos,
ids: newIds,
entities: newTodos
}
}
})
}
// небольшой финт для упрощения обновления задачи
const inputStyles = {
outline: 'none',
border: 'none',
background: 'none',
textAlign: 'center',
textDecoration: completed ? 'line-through' : '',
opacity: completed ? '0.8' : '1'
}
return (
<ListGroup.Item className='d-flex align-items-baseline'>
<Form.Check
type='checkbox'
checked={completed}
onChange={toggleTodo}
/>
<Form.Control
style={inputStyles}
defaultValue={text}
onChange={updateTodo}
disabled={completed}
/>
<Button variant='danger' onClick={deleteTodo}>
Delete
</Button>
</ListGroup.Item>
)
}
В components/index.js мы выполняем повторный экспорт компонентов:
export { TodoForm } from './TodoForm'
export { TodoList } from './TodoList'
Файл «scr/index.js» выглядит следующим образом:
import React from 'react'
import { render } from 'react-dom'
// стили
import 'bootstrap/dist/css/bootstrap.min.css'
// компонент
import App from './use-state/App'
const root$ = document.getElementById('root')
render(<App />, root$)
Основные проблемы данного подхода к управлению состоянием:
- Необходимость передачи состояния и/или сеттера на каждом уровне вложенности, обусловленная локальным характером состояния
- Логика обновления состояния приложения разбросана по компонентам и смешана с логикой самих компонентов
- Сложность обновления состояния, вытекающая из его иммутабельности
- Однонаправленный поток данных, невозможность свободного обмена данными между компонентами, находящимися на одном уровне вложенности, но в разных поддеревьях виртуального DOM
Первые две проблемы можно решить с помощью комбинации useContext()/ useReducer().
useContext() + useReducer()
Шпаргалка по хукам
Контекст (context) позволяет передавать значения дочерним компонентам напрямую, минуя их предков. Хук «useContext()» позволяет извлекать значения из контекста в любом компоненте, обернутом в провайдер (provider).
Создание контекста:
const TodoContext = createContext()
Предоставление контекста с состоянием дочерним компонентам:
<TodoContext.Provider value={state}>
<App />
</TodoContext.Provider>
Извлечение значения состония из контекста в компоненте:
const state = useContext(TodoContext)
Хук «useReducer()» принимает редуктор (reducer) и начальное состояние. Он возвращает значение текущего состояния и функцию для отправки (dispatch) операций (actions), на основе которых осуществляется обновление состояния. Сигнатура данного хука:
const [state, dispatch] = useReducer(todoReducer, initialState)
Алгоритм обновления состояния выглядит так: компонент отправляет операцию в редуктор, а редуктор на основе типа операции (action.type) и опциональной полезной нагрузки операции (action.payload) определенным образом изменяет состояния.
Результатом комбинации useContext() и useReducer() является возможность передачи состояния и диспетчера, возвращаемых useReducer(), любому компоненту, являющемуся потомком провайдера контекста.
Создаем директорию «use-reducer» для второго варианта тудушки. Структура проекта:
|--use-reducer
|--modules
|--components
|--index.js
|--TodoForm.js
|--TodoList.js
|--TodoListItem.js
|--todoReducer
|--actions.js
|--actionTypes.js
|--todoReducer.js
|--todoContext.js
|--App.js
Начнем с редуктора. В actionTypes.js мы просто определяем типы (названия, константы) операций:
const ADD_TODO = 'ADD_TODO'
const TOGGLE_TODO = 'TOGGLE_TODO'
const UPDATE_TODO = 'UPDATE_TODO'
const DELETE_TODO = 'DELETE_TODO'
export { ADD_TODO, TOGGLE_TODO, UPDATE_TODO, DELETE_TODO }
Типы операций определяются в отдельном файле, поскольку используются как при создании объектов операции, так и при выборе редуктора случая (case reducer) в инструкции «switch». Существует другой подход, когда типы, создатели операции и редуктор размещаются в одном файле. Такой подход назвается «утиной» структурой файла.
В actions.js определяются так называемые создатели операций (action creators), возвращающие объекты определенной формы (для редуктора):
import { ADD_TODO, TOGGLE_TODO, UPDATE_TODO, DELETE_TODO } from './actionTypes'
const createAction = (type, payload) => ({ type, payload })
const addTodo = (newTodo) => createAction(ADD_TODO, newTodo)
const toggleTodo = (todoId) => createAction(TOGGLE_TODO, todoId)
const updateTodo = (payload) => createAction(UPDATE_TODO, payload)
const deleteTodo = (todoId) => createAction(DELETE_TODO, todoId)
export { addTodo, toggleTodo, updateTodo, deleteTodo }
В todoReducer.js определяется сам редуктор. Еще раз: редуктор принимает состояние приложения и операцию, отправленную из компонента, и на основе типа операции (и полезной нагрузки) выполняет определенные действия, приводящие к обновлению состояния. Обновление состояния выполняется точно также, как в предыдущем варианте тудушки, только вместо setState() редуктор возвращает новое состояние.
// утилита для генерации ID
import { nanoid } from 'nanoid'
// типы операций
import * as actions from './actionTypes'
export const todoReducer = (state, action) => {
const { todos } = state
switch (action.type) {
case actions.ADD_TODO: {
const { payload: newTodo } = action
const id = nanoid(5)
return {
...state,
todos: {
...todos,
ids: todos.ids.concat(id),
entities: {
...todos.entities,
[id]: { id, ...newTodo }
}
}
}
}
case actions.TOGGLE_TODO: {
const { payload: id } = action
return {
...state,
todos: {
...todos,
entities: {
...todos.entities,
[id]: {
...todos.entities[id],
completed: !todos.entities[id].completed
}
}
}
}
}
case actions.UPDATE_TODO: {
const { payload: id, text } = action
return {
...state,
todos: {
...todos,
entities: {
...todos.entities,
[id]: {
...todos.entities[id],
text
}
}
}
}
}
case actions.DELETE_TODO: {
const { payload: id } = action
const newIds = todos.ids.filter((_id) => _id !== id)
const newTodos = newIds.reduce((obj, id) => {
if (todos.entities[id]) return { ...obj, [id]: todos.entities[id] }
else return obj
}, {})
return {
...state,
todos: {
...todos,
ids: newIds,
entities: newTodos
}
}
}
// по умолчанию (при отсутствии совпадения со всеми case) редуктор возвращает состояние в неизменном виде
default:
return state
}
}
В todoContext.js определяется начальное состояние приложения, создается и экспортируется провайдер контекста со значением состояния и диспетчером из useReducer():
// react
import { createContext, useReducer, useContext } from 'react'
// редуктор
import { todoReducer } from './todoReducer/todoReducer'
// создаем контекст
const TodoContext = createContext()
// начальное состояние
const initialState = {
todos: {
ids: ['1', '2', '3', '4'],
entities: {
1: {
id: '1',
text: 'Eat',
completed: true
},
2: {
id: '2',
text: 'Code',
completed: true
},
3: {
id: '3',
text: 'Sleep',
completed: false
},
4: {
id: '4',
text: 'Repeat',
completed: false
}
}
}
}
// провайдер
export const TodoProvider = ({ children }) => {
const [state, dispatch] = useReducer(todoReducer, initialState)
return (
<TodoContext.Provider value={{ state, dispatch }}>
{children}
</TodoContext.Provider>
)
}
// утилита для извлечения значений из контекста
export const useTodoContext = () => useContext(TodoContext)
В этом случае src/index.js выглядит так:
// React, ReactDOM и стили
import { TodoProvider } from './use-reducer/modules/TodoContext'
import App from './use-reducer/App'
const root$ = document.getElementById('root')
render(
<TodoProvider>
<App />
</TodoProvider>,
root$
)
Теперь у нас нет необходимости передавать состояние и функцию для его обновления на каждом уровне вложенности компонентов. Компонент извлекает состояние и диспетчера с помощью useTodoContext(), например:
import { useTodoContext } from '../TodoContext'
// в компоненте
const { state, dispatch } = useTodoContext()
Операции отправляются в редуктор с помощью dispatch(), которому передается создатель операции, которому может передаваться полезная нагрузка:
import * as actions from '../todoReducer/actions'
// в компоненте
dispatch(actions.addTodo(newTodo))
Код компонентов
App.js:
TodoForm.js:
TodoList.js:
TodoListItem.js:
// components
import { TodoForm, TodoList } from './modules/components'
// styles
import { Container } from 'react-bootstrap'
// context
import { useTodoContext } from './modules/TodoContext'
export default function App() {
const { state } = useTodoContext()
const { length } = state.todos.ids
return (
<Container style={{ maxWidth: '480px' }} className='text-center'>
<h1 className='mt-2'>useReducer</h1>
<TodoForm />
{length ? <TodoList /> : null}
</Container>
)
}
TodoForm.js:
// react
import { useState } from 'react'
// styles
import { Container, Form, Button } from 'react-bootstrap'
// context
import { useTodoContext } from '../TodoContext'
// actions
import * as actions from '../todoReducer/actions'
export const TodoForm = () => {
const { dispatch } = useTodoContext()
const [text, setText] = useState('')
const updateText = ({ target: { value } }) => {
setText(value)
}
const handleAddTodo = (e) => {
e.preventDefault()
const trimmed = text.trim()
if (trimmed) {
const newTodo = { text, completed: false }
dispatch(actions.addTodo(newTodo))
setText('')
}
}
return (
<Container className='mt-4'>
<h4>Form</h4>
<Form className='d-flex' onSubmit={handleAddTodo}>
<Form.Control
type='text'
placeholder='Enter text...'
value={text}
onChange={updateText}
/>
<Button variant='primary' type='submit'>
Add
</Button>
</Form>
</Container>
)
}
TodoList.js:
// components
import { TodoListItem } from './TodoListItem'
// styles
import { Container, ListGroup } from 'react-bootstrap'
// context
import { useTodoContext } from '../TodoContext'
export const TodoList = () => {
const {
state: { todos }
} = useTodoContext()
return (
<Container className='mt-2'>
<h4>List</h4>
<ListGroup>
{todos.ids.map((id) => (
<TodoListItem key={id} todo={todos.entities[id]} />
))}
</ListGroup>
</Container>
)
}
TodoListItem.js:
// styles
import { ListGroup, Form, Button } from 'react-bootstrap'
// context
import { useTodoContext } from '../TodoContext'
// actions
import * as actions from '../todoReducer/actions'
export const TodoListItem = ({ todo }) => {
const { dispatch } = useTodoContext()
const { id, text, completed } = todo
const handleUpdateTodo = ({ target: { value } }) => {
const trimmed = value.trim()
if (trimmed) {
dispatch(actions.updateTodo({ id, trimmed }))
}
}
const inputStyles = {
outline: 'none',
border: 'none',
background: 'none',
textAlign: 'center',
textDecoration: completed ? 'line-through' : '',
opacity: completed ? '0.8' : '1'
}
return (
<ListGroup.Item className='d-flex align-items-baseline'>
<Form.Check
type='checkbox'
checked={completed}
onChange={() => dispatch(actions.toggleTodo(id))}
/>
<Form.Control
style={inputStyles}
defaultValue={text}
onChange={handleUpdateTodo}
disabled={completed}
/>
<Button variant='danger' onClick={() => dispatch(actions.deleteTodo(id))}>
Delete
</Button>
</ListGroup.Item>
)
}
Таким образом, мы решили две первые проблемы, связанные с использованием useState() в качестве инструмента для управления состоянием. На самом деле, прибегнув к помощи одной интересной библиотеки, мы можем решить и третью проблему — сложность обновления состояния. immer позволяет безопасно мутировать иммутабельные значения (да, я знаю, как это звучит), для этого достаточно обернуть редуктор в функцию «produce()». Создадим файл «todoReducer/todoProducer.js»:
// утилита, предоставляемая immer
import produce from 'immer'
import { nanoid } from 'nanoid'
// типы операций
import * as actions from './actionTypes'
// сравните с "классической" реализацией редуктора
// для обновления состояния используется draft - черновик исходного состояния
export const todoProducer = produce((draft, action) => {
const {
todos: { ids, entities }
} = draft
switch (action.type) {
case actions.ADD_TODO: {
const { payload: newTodo } = action
const id = nanoid(5)
ids.push(id)
entities[id] = { id, ...newTodo }
break
}
case actions.TOGGLE_TODO: {
const { payload: id } = action
entities[id].completed = !entities[id].completed
break
}
case actions.UPDATE_TODO: {
const { payload: id, text } = action
entities[id].text = text
break
}
case actions.DELETE_TODO: {
const { payload: id } = action
ids.splice(ids.indexOf(id), 1)
delete entities[id]
break
}
default:
return draft
}
})
Главное ограничение, накладываемое immer, состоит в том, что мы должны либо мутировать состояние напрямую, либо возвращать состояние, обновленное иммутабельно. Нельзя делать и то, и другое одновременно.
Вносим изменения в todoContext.js:
// import { todoReducer } from './todoReducer/todoReducer'
import { todoProducer } from './todoReducer/todoProducer'
// в провайдере
// const [state, dispatch] = useReducer(todoReducer, initialState)
const [state, dispatch] = useReducer(todoProducer, initialState)
Все работает, как и прежде, но код редуктора стало легче читать и анализировать.
Двигаемся дальше.
Redux Toolkit
Руководство по Redux Toolkit
Redux Toolkit — это набор инструментов, облегчающий работу с Redux. Сам по себе Redux очень похож на то, что мы реализовали с помощью useContext() + useReducer():
- Состояние всего приложения находится в одном хранилище (store)
- Дочерние компоненты оборачиваются в Provider из react-redux, которому в виде пропа «store» передается хранилище
- Редукторы (reducers) каждой части состояния объединяются с помощью combineReducers() в один корневой редуктор (root reducer), который передается при создании хранилища в createStore()
- Компоненты подключаются к хранилищу с помощью connect() (+ mapStateToProps(), mapDispatchToProps()) и т.д.
Для реализации основных операций мы воспользуемся следующими утилитами из Redux Toolkit:
- configureStore() — для создания и настройки хранилища
- createSlice() — для создания частей состояния
- createEntityAdapter() — для создания адаптера сущностей
Чуть позже мы расширим функционал списка задач с помощью следующих утилит:
- createSelector() — для создания селекторов
- createAsyncThunk() — для создания преобразователей (thunk)
Также в компонентах мы будем использовать следующие хуки из react-redux: «useDispatch()» — для получения доступа к диспетчеру и «useSelector()» — для получения доступа к селекторам.
Создаем директорию «redux-toolkit» для третьего варианта тудушки. Устанавливаем Redux Toolkit:
yarn add @reduxjs/toolkit
# или
npm i @reduxjs/toolkit
Структура проекта:
|--redux-toolkit
|--modules
|--components
|--index.js
|--TodoForm.js
|--TodoList.js
|--TodoListItem.js
|--slices
|--todosSlice.js
|--App.js
|--store.js
Начнем с хранилища. store.js:
// утилита для создания хранилища
import { configureStore } from '@reduxjs/toolkit'
// редуктор
import todosReducer from './modules/slices/todosSlice'
// начальное состояние
const preloadedState = {
todos: {
ids: ['1', '2', '3', '4'],
entities: {
1: {
id: '1',
text: 'Eat',
completed: true
},
2: {
id: '2',
text: 'Code',
completed: true
},
3: {
id: '3',
text: 'Sleep',
completed: false
},
4: {
id: '4',
text: 'Repeat',
completed: false
}
}
}
}
// хранилище
const store = configureStore({
reducer: {
todos: todosReducer
},
preloadedState
})
export default store
В этом случае src/index.js выглядит так:
// React, ReactDOM & стили
// провайдер
import { Provider } from 'react-redux'
// основной компонент
import App from './redux-toolkit/App'
// хранилище
import store from './redux-toolkit/store'
const root$ = document.getElementById('root')
render(
<Provider store={store}>
<App />
</Provider>,
root$
)
Переходим к редуктору. slices/todosSlice.js:
// утилиты для создания части состояния и адаптера сущностей
import {
createSlice,
createEntityAdapter
} from '@reduxjs/toolkit'
// создаем адаптер
const todosAdapter = createEntityAdapter()
// инициализируем начальное состояние
// получаем { ids: [], entities: {} }
const initialState = todosAdapter.getInitialState()
// создаем часть состояния
const todosSlice = createSlice({
// уникальный ключ, используемый в качестве префикса при генерации создателей операции
name: 'todos',
// начальное состояние
initialState,
// редукторы
reducers: {
// данный создатель операции отправляет в редуктор операцию { type: 'todos/addTodo', payload: newTodo }
addTodo: todosAdapter.addOne,
// Redux Toolkit использует immer для обновления состояния
toggleTodo(state, action) {
const { payload: id } = action
const todo = state.entities[id]
todo.completed = !todo.completed
},
updateTodo(state, action) {
const { id, text } = action.payload
const todo = state.entities[id]
todo.text = text
},
deleteTodo: todosAdapter.removeOne
}
})
// экспортируем селектор для получения всех entities в виде массива
export const { selectAll: selectAllTodos } = todosAdapter.getSelectors(
(state) => state.todos
)
// экспортируем создателей операции
export const {
addTodo,
toggleTodo,
updateTodo,
deleteTodo
} = todosSlice.actions
// эскпортируем редуктор
export default todosSlice.reducer
В компоненте для доступа к диспетчеру используется useDispatch(), а для отправки конкретной операции создатель операции, импортируемый из todosSlice.js:
import { useDispatch } from 'react-redux'
import { addTodo } from '../slices/todosSlice'
// в компоненте
const dispatch = useDispatch()
dispatch(addTodo(newTodo))
Давайте немного расширим функционал нашей тудушки, а именно: добавим возможность фильтрации задач, кнопки для выполнения всех задач и удаления выполненных задач, а также некоторую полезную статистику. Также давайте реализуем получение списка задач с сервера.
Начнем с сервера.
В качестве «fake API» мы будем использовать JSON Server. Вот шпаргалка по работе с ним. Устанавливаем json-server и concurrently — утилиту для выполнения двух и более команд:
yarn add json-server concurrently
# или
npm i json-server concurrently
Вносим изменения в раздел «scripts» package.json:
"server": "concurrently \"json-server -w db.json -p 5000 -d 1000\" \"yarn start\""
- -w — означает наблюдение за изменениями файла «db.json»
- -p — означает порт, по умолчанию запросы из приложения отправляются на порт 3000
- -d — задержка ответа от сервера
Создаем файл «db.json» в корневой директории проекта (state-management):
{
"todos": [
{
"id": "1",
"text": "Eat",
"completed": true,
"visible": true
},
{
"id": "2",
"text": "Code",
"completed": true,
"visible": true
},
{
"id": "3",
"text": "Sleep",
"completed": false,
"visible": true
},
{
"id": "4",
"text": "Repeat",
"completed": false,
"visible": true
}
]
}
По умолчанию все запросы из приложения отправляются на порт 3000 (порт, на котором запущен сервер для разработки). Для того, чтобы запросы отправлялись на порт 5000 (порт, на котором будет работать json-server), необходимо их проксировать. Добавляем в package.json следующую строку:
"proxy": "http://localhost:5000"
Запускаем сервер с помощью команды «yarn server».
Создаем еще одну часть состояния. slices/filterSlice.js:
import { createSlice } from '@reduxjs/toolkit'
// фильтры
export const Filters = {
All: 'all',
Active: 'active',
Completed: 'completed'
}
// начальное состояние - отображать все задачи
const initialState = {
status: Filters.All
}
// состояние фильтра
const filterSlice = createSlice({
name: 'filter',
initialState,
reducers: {
setFilter(state, action) {
state.status = action.payload
}
}
})
export const { setFilter } = filterSlice.actions
export default filterSlice.reducer
Вносим изменения в store.js:
// нам больше не требуется preloadedState
import { configureStore } from '@reduxjs/toolkit'
import todosReducer from './modules/slices/todosSlice'
import filterReducer from './modules/slices/filterSlice'
const store = configureStore({
reducer: {
todos: todosReducer,
filter: filterReducer
}
})
export default store
Вносим изменения в todosSlice.js:
import {
createSlice,
createEntityAdapter,
// утилита для создания селекторов
createSelector,
// утилита для создания преобразователей
createAsyncThunk
} from '@reduxjs/toolkit'
// утилита для выполнения HTTP-запросов
import axios from 'axios'
// фильтры
import { Filters } from './filterSlice'
const todosAdapter = createEntityAdapter()
const initialState = todosAdapter.getInitialState({
// добавляем в начальное состояние статус загрузки
status: 'idle'
})
// адрес сервера
const SERVER_URL = 'http://localhost:5000/todos'
// преобразователь
export const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => {
try {
const response = await axios(SERVER_URL)
return response.data
} catch (err) {
console.error(err.toJSON())
}
})
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
addTodo: todosAdapter.addOne,
toggleTodo(state, action) {
const { payload: id } = action
const todo = state.entities[id]
todo.completed = !todo.completed
},
updateTodo(state, action) {
const { id, text } = action.payload
const todo = state.entities[id]
todo.text = text
},
deleteTodo: todosAdapter.removeOne,
// создатель операции для выполнения всех задач
completeAllTodos(state) {
Object.values(state.entities).forEach((todo) => {
todo.completed = true
})
},
// создатель операции для очистки выполненных задач
clearCompletedTodos(state) {
const completedIds = Object.values(state.entities)
.filter((todo) => todo.completed)
.map((todo) => todo.id)
todosAdapter.removeMany(state, completedIds)
}
},
// дополнительные редукторы
extraReducers: (builder) => {
builder
// после начала выполнения запроса на получения задач
// меняем значение статуса на loading
// это позволит отображать индикатор загрузки в App.js
.addCase(fetchTodos.pending, (state) => {
state.status = 'loading'
})
// после получения задач от сервера
// записываем их в состояние
// и меняем статус загрузки
.addCase(fetchTodos.fulfilled, (state, action) => {
todosAdapter.setAll(state, action.payload)
state.status = 'idle'
})
}
})
export const { selectAll: selectAllTodos } = todosAdapter.getSelectors(
(state) => state.todos
)
// создаем и экспортируем кастомный селектор для получения отфильтрованных задач
export const selectFilteredTodos = createSelector(
selectAllTodos,
(state) => state.filter,
(todos, filter) => {
const { status } = filter
if (status === Filters.All) return todos
return status === Filters.Active
? todos.filter((todo) => !todo.completed)
: todos.filter((todo) => todo.completed)
}
)
export const {
addTodo,
toggleTodo,
updateTodo,
deleteTodo,
completeAllTodos,
clearCompletedTodos
} = todosSlice.actions
export default todosSlice.reducer
Вносим изменения в src/index.js:
// после импорта компонента "App"
import { fetchTodos } from './redux-toolkit/modules/slices/todosSlice'
store.dispatch(fetchTodos())
App.js выглядит так:
// хук для доступа к селекторам
import { useSelector } from 'react-redux'
// индикатор загрузки - спиннер
import Loader from 'react-loader-spinner'
// компоненты
import {
TodoForm,
TodoList,
TodoFilters,
TodoControls,
TodoStats
} from './modules/components'
// стили
import { Container } from 'react-bootstrap'
// селектор для получения всех entitites в виде массива
import { selectAllTodos } from './modules/slices/todosSlice'
export default function App() {
// получаем длину массива сущностей
const { length } = useSelector(selectAllTodos)
// получаем значение статуса
const loadingStatus = useSelector((state) => state.todos.status)
// стили для индикатора загрузки
const loaderStyles = {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)'
}
if (loadingStatus === 'loading')
return (
<Loader
type='Oval'
color='#00bfff'
height={80}
width={80}
style={loaderStyles}
/>
)
return (
<Container style={{ maxWidth: '480px' }} className='text-center'>
<h1 className='mt-2'>Redux Toolkit</h1>
<TodoForm />
{length ? (
<>
<TodoStats />
<TodoFilters />
<TodoList />
<TodoControls />
</>
) : null}
</Container>
)
}
Код остальных компонентов
TodoControls.js:
TodoFilters.js:
TodoForm.js:
TodoList.js:
TodoListItem.js:
TodoStats.js:
// redux
import { useDispatch } from 'react-redux'
// styles
import { Container, ButtonGroup, Button } from 'react-bootstrap'
// action creators
import { completeAllTodos, clearCompletedTodos } from '../slices/todosSlice'
export const TodoControls = () => {
const dispatch = useDispatch()
return (
<Container className='mt-2'>
<h4>Controls</h4>
<ButtonGroup>
<Button
variant='outline-secondary'
onClick={() => dispatch(completeAllTodos())}
>
Complete all
</Button>
<Button
variant='outline-secondary'
onClick={() => dispatch(clearCompletedTodos())}
>
Clear completed
</Button>
</ButtonGroup>
</Container>
)
}
TodoFilters.js:
// redux
import { useDispatch, useSelector } from 'react-redux'
// styles
import { Container, Form } from 'react-bootstrap'
// filters & action creator
import { Filters, setFilter } from '../slices/filterSlice'
export const TodoFilters = () => {
const dispatch = useDispatch()
const { status } = useSelector((state) => state.filter)
const changeFilter = (filter) => {
dispatch(setFilter(filter))
}
return (
<Container className='mt-2'>
<h4>Filters</h4>
{Object.keys(Filters).map((key) => {
const value = Filters[key]
const checked = value === status
return (
<Form.Check
key={value}
inline
label={value.toUpperCase()}
type='radio'
name='filter'
onChange={() => changeFilter(value)}
checked={checked}
/>
)
})}
</Container>
)
}
TodoForm.js:
// react
import { useState } from 'react'
// redux
import { useDispatch } from 'react-redux'
// libs
import { nanoid } from 'nanoid'
// styles
import { Container, Form, Button } from 'react-bootstrap'
// action creator
import { addTodo } from '../slices/todosSlice'
export const TodoForm = () => {
const dispatch = useDispatch()
const [text, setText] = useState('')
const updateText = ({ target: { value } }) => {
setText(value)
}
const handleAddTodo = (e) => {
e.preventDefault()
const trimmed = text.trim()
if (trimmed) {
const newTodo = { id: nanoid(5), text, completed: false }
dispatch(addTodo(newTodo))
setText('')
}
}
return (
<Container className='mt-4'>
<h4>Form</h4>
<Form className='d-flex' onSubmit={handleAddTodo}>
<Form.Control
type='text'
placeholder='Enter text...'
value={text}
onChange={updateText}
/>
<Button variant='primary' type='submit'>
Add
</Button>
</Form>
</Container>
)
}
TodoList.js:
// redux
import { useSelector } from 'react-redux'
// component
import { TodoListItem } from './TodoListItem'
// styles
import { Container, ListGroup } from 'react-bootstrap'
// selector
import { selectFilteredTodos } from '../slices/todosSlice'
export const TodoList = () => {
const filteredTodos = useSelector(selectFilteredTodos)
return (
<Container className='mt-2'>
<h4>List</h4>
<ListGroup>
{filteredTodos.map((todo) => (
<TodoListItem key={todo.id} todo={todo} />
))}
</ListGroup>
</Container>
)
}
TodoListItem.js:
// redux
import { useDispatch } from 'react-redux'
// styles
import { ListGroup, Form, Button } from 'react-bootstrap'
// action creators
import { toggleTodo, updateTodo, deleteTodo } from '../slices/todosSlice'
export const TodoListItem = ({ todo }) => {
const dispatch = useDispatch()
const { id, text, completed } = todo
const handleUpdateTodo = ({ target: { value } }) => {
const trimmed = value.trim()
if (trimmed) {
dispatch(updateTodo({ id, trimmed }))
}
}
const inputStyles = {
outline: 'none',
border: 'none',
background: 'none',
textAlign: 'center',
textDecoration: completed ? 'line-through' : '',
opacity: completed ? '0.8' : '1'
}
return (
<ListGroup.Item className='d-flex align-items-baseline'>
<Form.Check
type='checkbox'
checked={completed}
onChange={() => dispatch(toggleTodo(id))}
/>
<Form.Control
style={inputStyles}
defaultValue={text}
onChange={handleUpdateTodo}
disabled={completed}
/>
<Button variant='danger' onClick={() => dispatch(deleteTodo(id))}>
Delete
</Button>
</ListGroup.Item>
)
}
TodoStats.js:
// react
import { useState, useEffect } from 'react'
// redux
import { useSelector } from 'react-redux'
// styles
import { Container, ListGroup } from 'react-bootstrap'
// selector
import { selectAllTodos } from '../slices/todosSlice'
export const TodoStats = () => {
const allTodos = useSelector(selectAllTodos)
const [stats, setStats] = useState({
total: 0,
active: 0,
completed: 0,
percent: 0
})
useEffect(() => {
if (allTodos.length) {
const total = allTodos.length
const completed = allTodos.filter((todo) => todo.completed).length
const active = total - completed
const percent = total === 0 ? 0 : Math.round((active / total) * 100) + '%'
setStats({
total,
active,
completed,
percent
})
}
}, [allTodos])
return (
<Container className='mt-2'>
<h4>Stats</h4>
<ListGroup horizontal>
{Object.entries(stats).map(([[first, ...rest], count], index) => (
<ListGroup.Item key={index}>
{first.toUpperCase() + rest.join('')}: {count}
</ListGroup.Item>
))}
</ListGroup>
</Container>
)
}
Как мы видим, с появлением Redux Toolkit использовать Redux для управления состоянием приложения стало проще, чем комбинацию useContext() + useReducer() (невероятно, но факт), не считая того, что Redux предоставляет больше возможностей для такого управления. Однако, Redux все-таки рассчитан на большие приложения со сложным состоянием. Существует ли какая-то альтернатива для управления состоянием небольших и средних приложений, кроме useContext()/useReducer(). Ответ: да, существует. Это Recoil.
Recoil
Руководство по Recoil
Recoil — это новый инструмент для управления состоянием в React-приложениях. Что значит новый? Это значит, что некоторые его API все еще находятся на стадии разработки и могут измениться в будущем. Однако, те возможности, которые мы будем использовать для создания тудушки, являются стабильными.
В основе Recoil лежат атомы и селекторы. Атом — это часть состояния, а селектор — часть производного состояния. Атомы создаются с помощью функции «atom()», а селекторы — с помощью функции «selector()». Для извлечение значений из атомов и селекторов используются хуки «useRecoilState()» (для чтения и записи), «useRecoilValue()» (только для чтения), «useSetRecoilState()» (только для записи) и др. Компоненты, использующие состояние Recoil, должны быть обернуты в RecoilRoot. По ощущениям, Recoil представляет собой промежуточное звено между useState() и Redux.
Создаем директорию «recoil» для последнего варианта тудушки и устанавливаем Recoil:
yarn add recoil
# или
npm i recoil
Структура проекта:
|--recoil
|--modules
|--atoms
|--filterAtom.js
|--todosAtom.js
|--components
|--index.js
|--TodoControls.js
|--TodoFilters.js
|--TodoForm.js
|--TodoList.js
|--TodoListItem.js
|--TodoStats.js
|--App.js
Вот как выглядит атом списка задач:
// todosAtom.js
// утилиты для создания атомов и селекторов
import { atom, selector } from 'recoil'
// утилита для выполнения HTTP-запросов
import axios from 'axios'
// адрес сервера
const SERVER_URL = 'http://localhost:5000/todos'
// атом с состоянием для списка задач
export const todosState = atom({
key: 'todosState',
default: selector({
key: 'todosState/default',
get: async () => {
try {
const response = await axios(SERVER_URL)
return response.data
} catch (err) {
console.log(err.toJSON())
}
}
})
})
Одной из интересных особенностей Recoil является то, что мы можем смешивать синхронную и асинхронную логику при создании атомов и селекторов. Он спроектирован таким образом, что у нас имеется возможность использовать React Suspense для отображения резервного контента до получения данных. Также у нас имеется возможность использовать предохранитель (ErrorBoundary) для перехвата ошибок, возникающих при создании атомов и селекторов, в том числе асинхронным способом.
В этом случае src/index.js выглядит так:
import React, { Component, Suspense } from 'react'
import { render } from 'react-dom'
// recoil
import { RecoilRoot } from 'recoil'
// индикатор загрузки
import Loader from 'react-loader-spinner'
import App from './recoil/App'
// предохранитель с официального сайта React
class ErrorBoundary extends Component {
constructor(props) {
super(props)
this.state = { error: null, errorInfo: null }
}
componentDidCatch(error, errorInfo) {
this.setState({
error: error,
errorInfo: errorInfo
})
}
render() {
if (this.state.errorInfo) {
return (
<div>
<h2>Something went wrong.</h2>
<details style={{ whiteSpace: 'pre-wrap' }}>
{this.state.error && this.state.error.toString()}
<br />
{this.state.errorInfo.componentStack}
</details>
</div>
)
}
return this.props.children
}
}
const loaderStyles = {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)'
}
const root$ = document.getElementById('root')
// мы оборачиваем основной компонент приложения сначала в Suspense, затем в ErrorBoundary
render(
<RecoilRoot>
<Suspense
fallback={
<Loader
type='Oval'
color='#00bfff'
height={80}
width={80}
style={loaderStyles}
/>
}
>
<ErrorBoundary>
<App />
</ErrorBoundary>
</Suspense>
</RecoilRoot>,
root$
)
Атом фильтра выглядит следующим образом:
// filterAtom.js
// recoil
import { atom, selector } from 'recoil'
// атом
import { todosState } from './todosAtom'
export const Filters = {
All: 'all',
Active: 'active',
Completed: 'completed'
}
export const todoListFilterState = atom({
key: 'todoListFilterState',
default: Filters.All
})
// данный селектор использует два атома: атом фильтра и атом списка задач
export const filteredTodosState = selector({
key: 'filteredTodosState',
get: ({ get }) => {
const filter = get(todoListFilterState)
const todos = get(todosState)
if (filter === Filters.All) return todos
return filter === Filters.Completed
? todos.filter((todo) => todo.completed)
: todos.filter((todo) => !todo.completed)
}
})
Компоненты извлекают значения из атомов и селекторов с помощью указанных выше хуков. Например, код компонента «TodoListItem» выглядит так:
// хук
import { useRecoilState } from 'recoil'
// стили
import { ListGroup, Form, Button } from 'react-bootstrap'
// атом
import { todosState } from '../atoms/todosAtom'
export const TodoListItem = ({ todo }) => {
// данный хук - это как useState() для состояния Recoil
const [todos, setTodos] = useRecoilState(todosState)
const { id, text, completed } = todo
const toggleTodo = () => {
const newTodos = todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
setTodos(newTodos)
}
const updateTodo = ({ target: { value } }) => {
const trimmed = value.trim()
if (!trimmed) return
const newTodos = todos.map((todo) =>
todo.id === id ? { ...todo, text: value } : todo
)
setTodos(newTodos)
}
const deleteTodo = () => {
const newTodos = todos.filter((todo) => todo.id !== id)
setTodos(newTodos)
}
const inputStyles = {
outline: 'none',
border: 'none',
background: 'none',
textAlign: 'center',
textDecoration: completed ? 'line-through' : '',
opacity: completed ? '0.8' : '1'
}
return (
<ListGroup.Item className='d-flex align-items-baseline'>
<Form.Check type='checkbox' checked={completed} onChange={toggleTodo} />
<Form.Control
style={inputStyles}
defaultValue={text}
onChange={updateTodo}
disabled={completed}
/>
<Button variant='danger' onClick={deleteTodo}>
Delete
</Button>
</ListGroup.Item>
)
}
Код остальных компонентов
TodoControls.js:
TodoFilters.js:
TodoForm.js:
TodoList.js:
TodoStats.js:
// recoil
import { useRecoilState } from 'recoil'
// styles
import { Container, ButtonGroup, Button } from 'react-bootstrap'
// atom
import { todosState } from '../atoms/todosAtom'
export const TodoControls = () => {
const [todos, setTodos] = useRecoilState(todosState)
const completeAllTodos = () => {
const newTodos = todos.map((todo) => (todo.completed = true))
setTodos(newTodos)
}
const clearCompletedTodos = () => {
const newTodos = todos.filter((todo) => !todo.completed)
setTodos(newTodos)
}
return (
<Container className='mt-2'>
<h4>Controls</h4>
<ButtonGroup>
<Button variant='outline-secondary' onClick={completeAllTodos}>
Complete all
</Button>
<Button variant='outline-secondary' onClick={clearCompletedTodos}>
Clear completed
</Button>
</ButtonGroup>
</Container>
)
}
TodoFilters.js:
// recoil
import { useRecoilState } from 'recoil'
// styles
import { Container, Form } from 'react-bootstrap'
// filters & atom
import { Filters, todoListFilterState } from '../atoms/filterAtom'
export const TodoFilters = () => {
const [filter, setFilter] = useRecoilState(todoListFilterState)
return (
<Container className='mt-2'>
<h4>Filters</h4>
{Object.keys(Filters).map((key) => {
const value = Filters[key]
const checked = value === filter
return (
<Form.Check
key={value}
inline
label={value.toUpperCase()}
type='radio'
name='filter'
onChange={() => setFilter(value)}
checked={checked}
/>
)
})}
</Container>
)
}
TodoForm.js:
// react
import { useState } from 'react'
// recoil
import { useSetRecoilState } from 'recoil'
// libs
import { nanoid } from 'nanoid'
// styles
import { Container, Form, Button } from 'react-bootstrap'
// atom
import { todosState } from '../atoms/todosAtom'
export const TodoForm = () => {
const [text, setText] = useState('')
const setTodos = useSetRecoilState(todosState)
const updateText = ({ target: { value } }) => {
setText(value)
}
const addTodo = (e) => {
e.preventDefault()
const trimmed = text.trim()
if (trimmed) {
const newTodo = { id: nanoid(5), text, completed: false }
setTodos((oldTodos) => oldTodos.concat(newTodo))
setText('')
}
}
return (
<Container className='mt-4'>
<h4>Form</h4>
<Form className='d-flex' onSubmit={addTodo}>
<Form.Control
type='text'
placeholder='Enter text...'
value={text}
onChange={updateText}
/>
<Button variant='primary' type='submit'>
Add
</Button>
</Form>
</Container>
)
}
TodoList.js:
// recoil
import { useRecoilValue } from 'recoil'
// components
import { TodoListItem } from './TodoListItem'
// styles
import { Container, ListGroup } from 'react-bootstrap'
// atom
import { filteredTodosState } from '../atoms/filterAtom'
export const TodoList = () => {
const filteredTodos = useRecoilValue(filteredTodosState)
return (
<Container className='mt-2'>
<h4>List</h4>
<ListGroup>
{filteredTodos.map((todo) => (
<TodoListItem key={todo.id} todo={todo} />
))}
</ListGroup>
</Container>
)
}
TodoStats.js:
// react
import { useState, useEffect } from 'react'
// recoil
import { useRecoilValue } from 'recoil'
// styles
import { Container, ListGroup } from 'react-bootstrap'
// atom
import { todosState } from '../atoms/todosAtom'
export const TodoStats = () => {
const todos = useRecoilValue(todosState)
const [stats, setStats] = useState({
total: 0,
active: 0,
completed: 0,
percent: 0
})
useEffect(() => {
if (todos.length) {
const total = todos.length
const completed = todos.filter((todo) => todo.completed).length
const active = total - completed
const percent = total === 0 ? 0 : Math.round((active / total) * 100) + '%'
setStats({
total,
active,
completed,
percent
})
}
}, [todos])
return (
<Container className='mt-2'>
<h4>Stats</h4>
<ListGroup horizontal>
{Object.entries(stats).map(([[first, ...rest], count], index) => (
<ListGroup.Item key={index}>
{first.toUpperCase() + rest.join('')}: {count}
</ListGroup.Item>
))}
</ListGroup>
</Container>
)
}
Заключение
Итак, мы с вами реализовали список задач с использованием четырех разных подходов к управлению состоянием. Какие выводы можно из всего этого сделать?
Я выскажу свое мнение, оно не претендует на статус истины в последней инстанции. Разумеется, выбор правильного инструмента для управления состоянием зависит от задач, решаемых приложением:
- Для управления локальным состоянием (состоянием одного-двух компонентов; при условии, что эти два компонента тесно связаны между собой) используйте useState()
- Для управления распределенным состоянием (состоянием двух и более автономных компонентов) или состоянием небольших и средних приложений используйте Recoil или сочетание useContext()/useReducer()
- Обратите внимание, что если вам нужно просто передавать значения в глубоко вложенные компоненты, то вам вполне хватит useContext() (useContext() сам по себе не является инструментом для управления состоянием)
- Наконец, для управления глобальным состоянием (состоянием всех или большинства компонентов) или состоянием сложного приложения используйте Redux Toolkit
Что касается MobX, то я слышал о нем много хорошего, но изучить как следует пока не успел.
Благодарю за внимание и хорошего дня.