March 19

Управление состоянием React приложения

Вопрос управления состоянием в React-приложениях всегда был очень актуален. Сам React, в момент первого выхода в свет, не предлагал никаких комплексных подходов к управлению состоянием и оставлял эту задачу на совести разработчиков. Из технических средств были доступны классические свойства компонентов и внутренний стейт компонентов. Стандартная базовая концепция была крайне примитивной и предполагала хранение данных в стейте компонента. Так, глобальные данные могли храниться в стейте самого верхнего компонента, а более специфичные - в стейтах нижних компонентов. Единственным штатным способом обмена данными между компонентами было использование свойств компонентов.

Prop-drilling

Типичный подход выглядел примерно следующим образом.

class App extends React.Component {
  constructor(props) {
    super(props);

    // инциализация стейта значениями по умолчанию
    this.state = {
      a: '',
      b: 0,
    };
  }

  render() {
    // проброс данных из стейта в дочерний компонент
    return <Child a={this.state.a} b={this.state.b} />;
  }
}

class Child extends React.Component<any, any> {
  // дочерний компонент получает данные через ссылку this.props
  render() {
    return (
      <div>
        <div>{this.props.a}</div>
        <div>{this.props.b}</div>
      </div>
    );
  }
}

ReactDOM.render(<App />, document.getElementById('root'));

В примере выше данные хранятся в стейте верхнего компонента и передаются в дочерний компонент через свойства. Дочерний компонент также может иметь свой внутренний стейт.

class Child extends React.Component {
  constructor(props) {
    super(props);

    this.state = {
      c: 2,
    };
  }

  render() {
    return (
      <div>
        <div>{this.props.a}</div>
        <div>{this.props.b}</div>
        <Child2 c={this.state.c} />
      </div>
    );
  }
}

class Child2 extends React.Component {
  render() {
    return (
      <div>
        <div>{this.props.c}</div>
      </div>
    );
  }
}

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

class App extends React.Component {
  constructor(props) {
    super(props);

    this.state = {
      a: '',
      b: 0,
    };
  }

  render() {
    return (
      <Child
        a={this.state.a}
        b={this.state.b}
        setB={(b) => {
          this.setState({ b });
        }}
      />
    );
  }
}

class Child extends React.Component<any, any> {
  constructor(props) {
    super(props);

    this.state = {
      c: 2,
    };
  }

  render() {
    return (
      <div>
        <div>{this.props.a}</div>
        <div>{this.props.b}</div>
        <Child2
          b={this.state.b}
          c={this.state.c}
          setB={this.props.setB}
        />
      </div>
    );
  }
}

class Child2 extends React.Component {
  render() {
    return (
      <div>
        <div>{this.props.c}</div>

        <button
          onClick={() => {
            this.props.setB(this.props.b + 1);
          }}
        >
          Increment
        </button>
      </div>
    );
  }
}

Уже на этом этапе проблема данного подхода становится очевидной. В реальных приложениях с большим набором компонентов эта схема приводит к так называемому prop-drilling, то есть массовому пробросу ссылок вниз по дереву компонентов и обратно наверх. Это неизбежно приводит:

а) к гонке обновления данных, то есть ситуация, когда два компонента, не зная друг о друге, пытаются обновить одну и ту же ссылку в стейте;

б) к тому, что компоненты должны выполнять транзитную роль для данных, которыми, фактически, не оперируют;

в) к сложности замены компонента в середине цепочки;

г) к длинному и запутанному графу ссылок на состояние.

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

const Child3 = ({ c }) => {
  // У функционального компонента нет this.state
  // хук useState() появился только в 2019-м году с версии 16.8.0
  return <div>{c}</div>;
};

Flux

Надо ли говорить, что показанный выше подход вел к большому количеству ошибок и экспоненциально нарастающей сложности кода? Не могли избежать этого и сами создатели React. В былые годы на сайте "Facebook" то и дело появлялись ошибки, связанные с различными асинхронными процессами, такими как исчезающие или не отображающиеся уведомления и другие. В связи с этим разработчики принялись за проработку нового подхода к организации управления состоянием данных в приложении. Так появился паттерн Flux. Прародителем этого паттерна стал классический и очень популярный в те времена паттерн MVC. Суть Flux заключается в том, что данные всегда должны двигаться в одном направлении и храниться в отдельном крупном хранилище. Обновление данных осуществляется с помощью специальных методов-экшенов, не привязанных к какому-либо конкретному компоненту.

На основе паттерна Flux появилось много библиотек для управления состоянием. Такие как Fluxxor, Flummox, Baobab и еще много других. Самой популярной из них, на протяжении многих лет и по сей день, является Redux.

Redux

Библиотека управления состоянием приложения Redux появилась в 2015 году и с тех пор приобрела огромную популярность. Redux доказал эффективность подхода Flux и до недавнего времени фактически являлся стандартом в мире React.

Архитектура типичного приложения обрела следующий вид.

import { connect, Provider } from 'react-redux';

const defaultState1 = {
  a: "",
  b: 0
};

const reducer1 = function(state = defaultState1, action) {
  switch(action.type){
    case "A_TYPE":
      return { ...state, a: action.payload };
    case "B_TYPE":
      return { ...state, b: action.payload };
  }

  return state;
};

const defaultState2 = {
  c: false,
  d: []
};

const reducer2 = function(state = defaultState2, action) {
  switch(action.type){
    case "C_TYPE":
      return { ...state, c: action.payload };
    case "D_TYPE":
      return { ...state, c: action.payload };
  }

  return state;
};

const store = createStore(
  combineReducers({
    reducer1,
    reducer2
  })
);

const App = connect(
  (state) => ({ b: state.reducer1.b }),
  (dispatch) => {
    setB: (value) => dispatch({ type: "B_TYPE", payload: value }) 
  }
)(({ b }) => {
  return (
    <Provider store={store}>
      <button
        onClick={() => {
          setB(b + 1)
        }}
      >
        Increase {b}
      </button>
    </Provider>
  );
});

Теперь все данные в приложении хранятся в отдельном большом дереве за пределами какого-либо компонента. К дереву можно «подключиться», т.е. слушать все изменения нужной ветки и реагировать на эти изменения. Обновить значение в дереве можно, послав так называемый экшен - событие, содержащее объект с полем type. По этому type редьюсер поймет, что это за экшен и выполнит соответствующую мутацию дерева.

В целом, вопрос, казалось бы, решен. Данные живут отдельно, компоненты отдельно. Никаких гонок и проп-дриллинга. Цена этому, правда, громоздкие конструкции по обеспечению жизнедеятельности самого хранилища. На каждое свойство дерева нужен редьюсер и экшен. А компонент необходимо подписывать на изменение всего дерева, чтобы получить данные. Кроме того, в более сложных приложениях, по мере роста дерева, начинают возникать специфические кейсы. Например, в глубоко вложенных объектах становится сложнее следить за иммутабельностью, что приводит к несрабатывающим реакциям на изменения стейта. На помощь пришли дополнительные библиотеки, такие как immutable-js и reselect.

Потом встал вопрос работы с запросами, ведь именно запросы в большинстве случаев являются источником данных в приложении. А значит, появилась необходимость добавить асинхронность в редюсеры. Так появились redux-thunk и redux-saga. Однако и этого было недостаточно.

Запросы, помимо данных, имеют еще и свое собственное состояние. Среднестатистическому приложению, как минимум, требуется знать, находится ли запрос еще в процессе или ответ уже получен. Традиционно, флаг выполнения запроса (обычно его называют "isFetching" или "isLoading") лежал тут же в дереве, что требовало на каждый запрос держать один и тот же механизм выставления этого флага. Вместе с флагом загрузки в такой же ситуации находилась и обработка ошибок запроса. Текст ошибки также хранился в дереве рядом с данными и флагом загрузки. Если копнуть еще глубже, продвинутые приложения в целях оптимизации захотели кэшировать ответы на запросы, чтобы не перезапрашивать данные по сети каждый раз, когда, к примеру, монтируется компонент. Все эти рутиные операции и оптимизации еще больше раздували и без того не маленький код, обслуживающий стейт-дерево. Поэтому появление таких библиотек, как, например, redux-toolkit (RTK) выглядит вполне логично. Библиотека RTK позволяет организовать работу со стейт-деревом в одном общем паттерне. А в сочетании с дополнительным rtk-query можно получить кэширование запросов и информацию об их состоянии из коробки. Правда, от экшенов и редюсеров избавиться таким образом все равно не получится.

useReducer

Redux и прочие Flux-подобные библиотеки являются сторонними разработками. Конечно же, команда React не осталась в стороне и добавила в API возможность работать в стиле Flux. Фактически, это выразилось в появлении хука useReducer в версии React 16.8.0.

Тот же пример, что был приведен выше, теперь можно оформить следующим образом без подключения Redux.

const reducer = (state, action) => {
  switch (action.type) {
    case 'A_TYPE':
      return { ...state, a: action.payload };
    case 'B_TYPE':
      return { ...state, b: action.payload };

    default:
      return state;
  }
};

export const App = () => {
  const [state, dispatch] = useReducer(reducer, { a: "", b: 0 });

  return (
    <button
      onClick={() => {
        dispatch({ type: "A_TYPE", payload: b + 1 })
      }}
    >
      Increase {state.b}
    </button>
  );
};

Однако все перечисленные ранее вопросы, связанные с работой Redux, справедливы и для useReducer. А поскольку Redux оброс массой дополнительных библиотек и инструментов, хук не получил большой популярности среди разработчиков.

React контекст

Контекст в React существовал с самых первых версий. Однако официально он был включен в API только начиная с версии 16.3.0 в 2018 году. Суть подхода заключается в следующем: данные, которые требуется сделать доступными для нескольких компонентов, помещаются в отдельное независимое место посредством метода API createContext().

const MyContext = createContext({ a: "", b: 0 });

Результатом метода createContext является объект, содержащий ссылки на провайдер и консьюмер созданного контекста.

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

class App extends React.Component<any, any> {
  render() {
    return (
      <MyContext.Provider value={{ a: '', b: 0 }}>
        <Child />
      </MyContext.Provider>
    );
  }
}

class Child extends React.Component<any, any> {
  contextType = MyContext;

  render() {
    return (
      <div>
        <div>{this.context.a}</div>
        <Child2 />
      </div>
    );
  }
}

const Child2 = () => {
  return (
    <MyContext.Consumer>
      {(context) => <div>{context.b}</div>}
    </MyContext.Consumer>
  );
};

Доступ к контексту осуществляется через ссылку-консьюмер. В классовом компоненте имеется возможность привязать контекст к компоненту, определив свойство contextType. В функциональных компонентах можно воспользоваться прямой ссылкой на консьюмер MyContext.Consumer.

С появлением официальной поддержки API контекста для функциональных компонентов стал доступен хук useContext.

const Child2 = () => {
  const { b } = useContext(MyContext);

  return (
    <div>{b}</div>
  );
};

За обновлением данных в контексте отвечает компонент, создавший его.

class App extends React.Component<any, any> {
  const [a, setA] = useState("");
  const [b, setB] = useState(0);

  render() {
    return (
      <MyContext.Provider value={{ a, b }}>
        <Child />
        <button onClick={() => setB(b => b + 1)}>
          Increase {b}
        </button>
      </MyContext.Provider>
    );
  }
}

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

class App extends React.Component<any, any> {
  const [a, setA] = useState("");
  const [b, setB] = useState(0);

  render() {
    return (
      <MyContext.Provider value={{ a, b, setB }}>
        <Child />
      </MyContext.Provider>
    );
  }
}

const Child = () => {
  const { b, setB } = useContext(MyContext);

  return (
    <button onClick={() => setB(b => b + 1)}>
      Increase {b}
    </button>
  );
};

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

class App extends React.Component<any, any> {
  const [a, setA] = useState("");
  const [b, setB] = useState(0);
  
  const increaseB = () => {
    setB((b) => {
      return b < 10 ? b + 1 : b;
    });
  };

  render() {
    return (
      <MyContext.Provider value={{ a, b, increaseB }}>
        <Child />
      </MyContext.Provider>
    );
  }
}

const Child = () => {
  const { b, increaseB } = useContext(MyContext);

  return (
    <button onClick={increaseB}>
      Increase {b}
    </button>
  );
};

К преимуществам контекста можно отнести

Гибкость

Хранилище контекста максимально примитивно, а API состоит из простых методов-обёрток. Следовательно, разработчик имеет максимальный контроль на всех этапах проектирования контекста.

Селективность

В отличие от Redux с его глобальным деревом, каждый конкретный контекст имеет возможность обернуть только нужные компоненты. И наоборот, компонент может быть обернут сразу в несколько контекстов. Это позволяет контролировать, какие данные и где будут доступны.

Уникальность

В ряде случаев может возникнуть необходимость продублировать модель контекста. Такое может понадобиться, например, при работе с массивами.

import { connect, Provider } from 'react-redux';

const defaultState = {
  items: [
    { id: 1, name: "item 1" },
    { id: 2, name: "item 2" },
    { id: 3, name: "item 3" },
  ],
  selectedId: undefined,
};

const reducer = function(state = defaultState, action) {
  switch(action.type){
    case "SET_SELECTED_ID_TYPE":
      return {
        ...state,
        selectedId: action.id,
      };
    default:
      return state;
  }

  return state;
};

const store = createStore(reducer);

const SelectedItem = connect(
  (state) => ({ item: state.reducer.items.find(item => item.id === state.selectedId) }),
)(({ item }) => {
  return item ? null : (
    <div>
      Seleceted item: {item?.title}
    </div>
  );
});

const ItemsList = connect(
  (state) => ({
    items: state.reducer.items,
  }),
  (dispatch = {
    setItem: (id) => dispatch({ type: 'SET_SELECTED_ID_TYPE', id }),
  })
)(({ items, setItem }) => {
  return (
    <div>
      {items.map((item) => (
        <button key={item.id} onClick={() => setItem(item.id)}>
          {item.title}
        </button>
      ))}

      <SelectedItem />
    </div>
  );
});

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

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

В случае с контекстом архитектура могла бы выглядеть, например, так.

const ItemContext = createContext();

const SelectedItem: FC = ({ title }) => {
  const { title } = useContext(ItemContext);

  return <div>Selected item: {title}</div>;
};

const ItemsList: FC = () => {
  const [items] = useState([
    { id: 1, name: 'item 1' },
    { id: 2, name: 'item 2' },
    { id: 3, name: 'item 3' },
  ]);

  const [selectedItemId, setSelectedItemId] = useState();

  const item = useMemo(
    () => items.find((item) => item.id === selectedItemId),
    [items, selectedItemId]
  );

  return (
    <div>
      {items.map((item) => (
        <button key={item.id} onClick={() => setSelectedItemId(item.id)}>
          {item.title}
        </button>
      ))}

      {item && (
        <ItemContext.Provider value={item}>
          <SelectedItem />
        </ItemContext.Provider>
      )}
    </div>
  );
};

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

В то же время, компонент SelectedItem оперирует контекстом ItemContext и вообще не знает, откуда и как в него попали данные.

Универсальный паттерн применения React контекста

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

Для начала создадим независимый контекст и отдельный компонент-провайдер для него.

// MyContext.tsx
import React, {
  createContext,
  Dispatch,
  FC,
  PropsWithChildren,
  SetStateAction,
  useCallback,
  useContext,
  useMemo,
  useState,
} from 'react';

export interface MyContextProps {
  myVar: string;
  setMyVar: Dispatch<SetStateAction<MyContextProps['myVar']>>;

  someAction: (a: number, b?: boolean) => void;
}

export const MyContext = createContext<MyContextProps>({
  myVar: '',
  setMyVar: () => {},

  someAction: () => {},
});

// Вместо WithMyContext можно использовать более традиционное
// название MyContextProvder
export const WithMyContext: FC<PropsWithChildren> = ({ children }) => {
  const [myVar, setMyVar] = useState<MyContextProps['myVar']>('');

  const someAction = useCallback<MyContextProps['someAction']>(
    (a, b) => {},
    []
  );

  const value = useMemo<MyContextProps>(
    () => ({
      myVar,
      setMyVar,

      someAction,
    }),
    [myVar, someAction]
  );

  return <MyContext.Provider value={value}>{children}</MyContext.Provider>;
};

// Создадим, также отдельный хук для удобства
export const useMyContext = () => {
  return useContext(MyContext);
};

Контекст готов, осталось обернуть в него нужный компонент.

<WithMyContext>
  <MyComponent />
</WithMyContext>

Или, например, конкретный роут "react-router"

<Route element={(
  <WithMyContext>
    <Outlet />
  </WithMyContext>
)}>
  <Route …>
</Route>

Теперь контекст доступен для использования внутри вложенных компонентов.

import { FC } from 'react';

import { useMyContext } from ‘./MyContext';

export const MyComponent: FC = () => {
  const { myVar } = useMyContext();

  return <div>My component {myVar}</div>;
};


Мои телеграмм-каналы:

EN - https://t.me/frontend_almanac
RU - https://t.me/frontend_almanac_ru

English version: https://blog.frontend-almanac.com/react-state-management