Как управлять состоянием React приложения без сторонних библиотек

в 13:07, , рубрики: javascript, React, ReactJS, redux, Разработка веб-сайтов

image

Реакт это все что вам нужно для управления состоянием вашего приложения.

Управление состоянием это одна из сложнейших задач при разработки приложения. Вот почему каждый день появляются все новые и новые библиотеки для управления состоянием, их становиться все больше и больше, причем многие из них разрабатываются поверх уже существующих решений. В npm вы можете найти сотни "упрощенных Redux" библиотек. Однако, несмотря на то что управлять состоянием сложно, одной из причин того почему так получилось стало именно то что мы слишком переусложняем решение проблемы.

Существует метод управления состоянием который лично я пытаюсь применять еще с тех пор как я начал использовать Реакт. И теперь, после релиза хуков (hooks) и улучшения контекстов (context), этот метод управления состояниями стало очень просто использовать.

О компонентах Реакта часто говорят как о детальках Лего конструктора, из которых мы собираем наши приложения. Но эту аналогию можно применить не только к компонентам, но и к состоянию приложения. "Секрет" моего подхода к управлению состоянием в том что состояние приложения должно соответствовать структуре самого приложения.

Причиной популярности Редакса (redux), помимо прочего, стало то что react-redux решал проблему проп дриллинга (prop drilling). Редакс позволил обмениваться данными между различными частями дерева компонентов, просто передавая компонент в магическую функцию connect. Другие возможности Редакса — редюсеры, экшены и прочее, конечно хороши, но я уверен что повсеместное использование Редакса связано именно с тем, что он позволил избавиться от проп дриллинга.

Проп дриллинг это такой анти-паттерн при котором передача пропсов происходит через промежуточные компоненты которые не используют получаемые пропсы, а только передают их в следующие компоненты.

Но применение Редакса может привести к различным проблемам. Я часто вижу как разработчики переносят все состояния приложения в Редакс. Не только глобальное состояние, но и локальные. Это приводит к тому что когда вы создаете любое взаимодействие с состоянием, оно запускает взаимодействие с редюсерами, генераторами/типами экшенов и вызовами dispatch (dispatch calls). Из-за этого, просто чтобы понять как и какие стейты оказывают влияние на приложение, вам нужно открывать кучу файлов и отслеживать весь написанный там код.

Не поймите меня неправильно, это хороший подход для состояний которые действительно должны быть глобальными, но для простых состояний (таких как, например, открыто ли модальное окно или состояние строки ввода у формы) это большая проблема. Что делает ситуацию еще хуже, так это то что такой подход плохо масштабируется. Чем больше становиться ваше приложение, тем хуже становиться эта проблема. Конечно, вы можете подключать различные редюсеры для управления теми или иными частями приложения, но косвенная обработка всех этих генераторов экшенов и редюсеров не является оптимальной.

Держать все состояние вашего приложения в одном объекте не лучшая идея и может привести к другим проблемам (в том числе если вы не используете для этого Редакс). Когда Реакт <Context.Provider> получает новое значение, все компоненты, которые используют это значение, обновляются и запускают рендер, даже если это функциональный компонент который отвечает только за какую-то часть данных. Это может привести к проблемам с производительностью. Что я хочу сказать — у вас не будет подобных проблем если ваше состояние разделено и находится в дереве компонентов Реакта таким образом чтобы быть как можно ближе к тем местам к которым это состояние и относиться.


Тут вот какое дело — если вы создаете приложение при помощи React, у вас уже установлен пакет для управления состоянием. Чтобы использовать его, вам не нужно применять npm install или yarn add. Этот пакет не добавляет лишних байтов в ваше приложение, он уже интегрирован со всеми библиотеками для Реакта, и он уже хорошо документирован командой Реакта. Это сам Реакт.

Реакт это библиотека для управления состоянием

Когда вы создаете приложение при помощи Реакта, вы собираете множество компонентов, чтобы создать дерево компонентов. Вы начинаете с вашего <App /> и заканчиваете низкоуровневыми <input />, <div /> и <button />. Вы не управляете всеми низкоуровневыми составными компонентами, которые ваше приложение рендерит, из какого-то централизованного места. Вместо этого вы позволяете каждому отдельному компоненту управлять им. Как оказалось, это действительно простой и эффективный способ создания UI.

Такой же подход можно применить и к состоянию:

function Counter() {
  const [count, setCount] = React.useState(0)
  const increment = () => setCount(c => c + 1)
  return <button onClick={increment}>{count}</button>
}

function App() {
  return <Counter />
}

Edit React Codesandbox

Имейте в виду что все, о чем здесь идет речь, работает и с классами. Хуки просто упрощают работу (особенно работу с контекстом, вскоре мы рассмотрим и такой вариант).

class Counter extends React.Component {
  state = {count: 0}
  increment = () => this.setState(({count}) => ({count: count + 1}))
  render() {
    return <button onClick={this.increment}>{this.state.count}</button>
  }
}

"Окей, это конечно легко — управлять одним элементом состояния в одном компоненте, но что если мне нужно разделить это состояние между компонентами? Например, что, если я хочу сделать это:

function CountDisplay() {
  // откуда нам брать значение для `count`?
  return <div>The current counter count is {count}</div>
}

function App() {
  return (
    <div>
      <CountDisplay />
      <Counter />
    </div>
  )
}

"Управление состоянием для подсчета значения происходит внутри <Counter />, выходи, теперь мне нужна библиотека управления состоянием, чтобы получить доступ к значению count для <CountDisplay /> и для его обновлений в <Counter />!"

Ответ этот вопрос настолько же стар, настолько стар и сам Реакт (или старше?), и был в документации столько, сколько я себя помню: Подъём состояния

Подъём состояния (Lifting State Up) это надежный и рекомендуемый способ управления состоянием в Реакте. Вот каким образом можно применять его:

function Counter({count, onIncrementClick}) {
  return <button onClick={onIncrementClick}>{count}</button>
}

function CountDisplay({count}) {
  return <div>The current counter count is {count}</div>
}

function App() {
  const [count, setCount] = React.useState(0)
  const increment = () => setCount(c => c + 1)
  return (
    <div>
      <CountDisplay count={count} />
      <Counter count={count} onIncrementClick={increment} />
    </div>
  )
}

Edit React Codesandbox

Теперь за управление состоянием отвечает другой компонент. При необходимости вы всегда можете поднять управление состоянием в вышестоящий компонент, вплоть до самого верхнего компонента.

"Да, конечно, но что на счет проблемы проп дриллинга (prop drilling)?"

На самом деле это проблема у которой уже довольно давно есть решение — контексты (context). На протяжении долгого времени люди применяли react-redux из-за предупреждений в документации Реакта об использовании контекстов. Но сейчас контексты это официально поддерживаемая часть React API, и мы можем использовать их напрямую:

// src/count/count-context.js
import React from 'react'

const CountContext = React.createContext()

function useCount() {
  const context = React.useContext(CountContext)
  if (!context) {
    throw new Error(`useCount must be used within a CountProvider`)
  }
  return context
}

function CountProvider(props) {
  const [count, setCount] = React.useState(0)
  const value = React.useMemo(() => [count, setCount], [count])
  return <CountContext.Provider value={value} {...props} />
}

export {CountProvider, useCount}

// src/count/page.js
import React from 'react'
import {CountProvider, useCount} from './count-context'

function Counter() {
  const [count, setCount] = useCount()
  const increment = () => setCount(c => c + 1)
  return <button onClick={increment}>{count}</button>
}

function CountDisplay() {
  const [count] = useCount()
  return <div>The current counter count is {count}</div>
}

function CountPage() {
  return (
    <div>
      <CountProvider>
        <CountDisplay />
        <Counter />
      </CountProvider>
    </div>
  )
}

Edit React Codesandbox

ПРИМЕЧАНИЕ. Этот код является просто примером, и я НЕ рекомендую использовать контекст для решения конкретно этой проблемы. В данном случае более простым решением стало бы просто передача состояний через пропсы (подробнее здесь: Prop Drilling). Не нужно применять контексты там где можно обойтись более простыми методами.

Одна из крутейших особенностей этого решения заключается в том что мы можем абстрагировать всю логику которую часто применяем для обновления состояния в наш useContext хук:

function useCount() {
  const context = React.useContext(CountContext)
  if (!context) {
    throw new Error(`useCount must be used within a CountProvider`)
  }
  const [count, setCount] = context

  const increment = () => setCount(c => c + 1)
  return {
    count,
    setCount,
    increment,
  }
}

Edit React Codesandbox

При желании можно поменять useState на useReducer:

function countReducer(state, action) {
  switch (action.type) {
    case 'INCREMENT': {
      return {count: state.count + 1}
    }
    default: {
      throw new Error(`Unsupported action type: ${action.type}`)
    }
  }
}

function CountProvider(props) {
  const [state, dispatch] = React.useReducer(countReducer, {count: 0})
  const value = React.useMemo(() => [state, dispatch], [state])
  return <CountContext.Provider value={value} {...props} />
}

function useCount() {
  const context = React.useContext(CountContext)
  if (!context) {
    throw new Error(`useCount must be used within a CountProvider`)
  }
  const [state, dispatch] = context
  const increment = () => dispatch({type: 'INCREMENT'})
  return {
    state,
    dispatch,
    increment,
  }
}

Edit React Codesandbox

Это дает нам гибкость и уменьшает сложность кода. Вот о чем следует помнить когда вы так делаете:

  1. Не нужно держать все в одном объекте состояния. Разделяйте логику состояния, используйте разные контексты для разных ситуаций, например, пользовательские настройки не обязательно должны находиться в контексте в котором уже находятся уведомления.

  2. Не нужно делать все контексты глобальными! Держите состояние как можно ближе к месту к которому оно относиться.

Подробнее о втором пункте. Структура вашего приложения может выглядеть примерно так:

function App() {
  return (
    <ThemeProvider>
      <AuthenticationProvider>
        <Router>
          <Home path="/" />
          <About path="/about" />
          <UserPage path="/:userId" />
          <UserSettings path="/settings" />
          <Notifications path="/notifications" />
        </Router>
      </AuthenticationProvider>
    </ThemeProvider>
  )
}

function Notifications() {
  return (
    <NotificationsProvider>
      <NotificationsTab />
      <NotificationsTypeList />
      <NotificationsList />
    </NotificationsProvider>
  )
}

function UserPage({username}) {
  return (
    <UserProvider username={username}>
      <UserInfo />
      <UserNav />
      <UserActivity />
    </UserProvider>
  )
}

function UserSettings() {
  // это специальный кастомный хук для AuthenticationProvider
  const {user} = useAuthenticatedUser()
}

Обратите внимание что у каждой страницы может быть свой собственный провайдер (provider) контекста, который передает данные необходимые компоненту находящемуся под ним. При таком подходе разделение кода (Code-Splitting) работает само по себе. То, как вы передаете данные в каждый провайдер, зависит от того как эти провайдеры используют хуки, и от того каким образом вы извлекаете данные в своем приложении. В любом случае чтобы понять как работает ваш контекст, в первую очередь вам нужно посмотреть в код компонента-провайдера.

Если хотите узнать больше о том что такое "совместное размещение", читайте статью State Colocation will make your React app faster (на русском как сделать React приложение быстрее при помощи совместного размещения состояний
) и Colocation. А если интересно почитать больше о работе с контекстами, читайте статью How to use React Context effectively

Кэш Сервера против Состояние Интерфейса

Существуют различные виды состояний приложения, но каждый тип состояния можно отнести к одной из этих категорий:

  1. Кэш Сервера (Server Cache) — состояние которое размещено на сервере для быстрого доступа к нему на клиенте (к примеру — данные пользователя).

  2. Состояние Интерфейса (UI State) — состояние в котором есть смысл только в интерфейсе пользователя, оно нужно для управления интерактивными частями приложения (к примеру, открытие модального окна — modal isOpen)

Мы совершаем ошибку когда относимся к этим двум одинаково. Кэш сервера сильно отличается от состояния UI, и к нему нужен иной подход. Если вы поняли что ваше состояние это вовсе не состояние, а кэш состояния, то вы начинаете лучше понимать ваше состояние и то как нужно им управлять.

Вы определенно можете управлять им при помощи ваших собственных useState или useReducer, с правильными useContext там и тут. Но, имейте в виду, кэширование это очень сложная проблема (некоторые говорят что это одна из сложнейших проблем в информатике), так что, касательно этого вопроса, будет разумно "встать на плечи гигантов".

Я сам использую, и всячески рекомендую библиотеку react-query для подобных состояний. Знаю, знаю, я сам сказал что вам не нужны библиотеки для управления состоянием, но, я не считаю что react-query это библиотека для управления состоянием. Я считаю что это библиотека для управления кэшем. И она офигенно хороша. Попробуйте ее. Этот парень — Tanner Linsley весьма умен.

Заключение

Как я уже говорил, все это вы можете реализовать применяя классовые компоненты (вам не обязательно использовать хуки). Хуки просто делают все намного проще, но вы можете реализовать эту философию и в React 15. Опускайте состояние как можно ниже по иерархии компонентов, и используйте контекст только тогда когда проп дриллинг реально станет проблемой. Все эти действия помогут вам упростить работу с состоянием вашего приложения.

Автор: Иван Ганев

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js