Русский
Русский
English
Статистика
Реклама

React основные подходы к управлению состоянием



Доброго времени суток, друзья!

Предлагаю вашему вниманию простое приложение список задач. Что в нем особенного, спросите вы. Дело в том, что я попытался реализовать одну и ту же тудушку с использованием четырех разных подходов к управлению состоянием в 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'// утилита для генерации IDimport { 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() редуктор возвращает новое состояние.

// утилита для генерации IDimport { 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():

// reactimport { 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:

// componentsimport { TodoForm, TodoList } from './modules/components'// stylesimport { Container } from 'react-bootstrap'// contextimport { 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:

// reactimport { useState } from 'react'// stylesimport { Container, Form, Button } from 'react-bootstrap'// contextimport { useTodoContext } from '../TodoContext'// actionsimport * 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:

// componentsimport { TodoListItem } from './TodoListItem'// stylesimport { Container, ListGroup } from 'react-bootstrap'// contextimport { 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:

// stylesimport { ListGroup, Form, Button } from 'react-bootstrap'// contextimport { useTodoContext } from '../TodoContext'// actionsimport * 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:

// утилита, предоставляемая immerimport 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.actionsexport default filterSlice.reducer

Вносим изменения в store.js:

// нам больше не требуется preloadedStateimport { 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.actionsexport 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:

// reduximport { useDispatch } from 'react-redux'// stylesimport { Container, ButtonGroup, Button } from 'react-bootstrap'// action creatorsimport { 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:

// reduximport { useDispatch, useSelector } from 'react-redux'// stylesimport { Container, Form } from 'react-bootstrap'// filters & action creatorimport { 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:

// reactimport { useState } from 'react'// reduximport { useDispatch } from 'react-redux'// libsimport { nanoid } from 'nanoid'// stylesimport { Container, Form, Button } from 'react-bootstrap'// action creatorimport { 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:

// reduximport { useSelector } from 'react-redux'// componentimport { TodoListItem } from './TodoListItem'// stylesimport { Container, ListGroup } from 'react-bootstrap'// selectorimport { 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:

// reduximport { useDispatch } from 'react-redux'// stylesimport { ListGroup, Form, Button } from 'react-bootstrap'// action creatorsimport { 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:

// reactimport { useState, useEffect } from 'react'// reduximport { useSelector } from 'react-redux'// stylesimport { Container, ListGroup } from 'react-bootstrap'// selectorimport { 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'// recoilimport { RecoilRoot } from 'recoil'// индикатор загрузкиimport Loader from 'react-loader-spinner'import App from './recoil/App'// предохранитель с официального сайта Reactclass 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, затем в ErrorBoundaryrender(  <RecoilRoot>    <Suspense      fallback={        <Loader          type='Oval'          color='#00bfff'          height={80}          width={80}          style={loaderStyles}        />      }    >      <ErrorBoundary>        <App />      </ErrorBoundary>    </Suspense>  </RecoilRoot>,  root$)

Атом фильтра выглядит следующим образом:

// filterAtom.js// recoilimport { 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:

// recoilimport { useRecoilState } from 'recoil'// stylesimport { Container, ButtonGroup, Button } from 'react-bootstrap'// atomimport { 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:

// recoilimport { useRecoilState } from 'recoil'// stylesimport { Container, Form } from 'react-bootstrap'// filters & atomimport { 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:

// reactimport { useState } from 'react'// recoilimport { useSetRecoilState } from 'recoil'// libsimport { nanoid } from 'nanoid'// stylesimport { Container, Form, Button } from 'react-bootstrap'// atomimport { 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:

// recoilimport { useRecoilValue } from 'recoil'// componentsimport { TodoListItem } from './TodoListItem'// stylesimport { Container, ListGroup } from 'react-bootstrap'// atomimport { 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:

// reactimport { useState, useEffect } from 'react'// recoilimport { useRecoilValue } from 'recoil'// stylesimport { Container, ListGroup } from 'react-bootstrap'// atomimport { 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, то я слышал о нем много хорошего, но изучить как следует пока не успел.

Благодарю за внимание и хорошего дня.
Источник: habr.com
К списку статей
Опубликовано: 12.03.2021 08:08:07
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Разработка веб-сайтов

Javascript

Программирование

Reactjs

React

React.js

State

State management

Usestate

Usecontext

Usereducer

Redux

Redux toolkit

Recoil

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru