Подводные камни асинхронных операций через React Context API

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

Все начинается с простого компонента

Давайте представим, что мы хотим создать раскрывающийся список, который отображает список категорий: мы можем использовать GraphQL через React-Apollo, но, предполагая, что мы еще не можем, мы тем временем создаем что-то с аналогичным API, легко заменяемым в будущем. Мы начинаем нашу первую итерацию, просто используя локальное состояние:

class CategoriesQuery extends Component {
  state = { data: null, loading: false, error: null };
  fetch = async () => {
    this.setState({ loading: true });
    try {
      const data = await fetch('/categories');
      this.setState({ data, loading: false })
    } catch (error) {
      this.setState({ error, loading: false })
    } 
  };
  componentDidMount() { 
    this.fetch();
  }
  render () {
    return this.props.children(this.state);
  } 
}
// usage
const CategoriesDropdown = ( 
  <CategoriesQuery>
    ({ data, loading, error }) => <Dropdown ... />
  </CategoriesQuery>
)

Довольно чисто, правда? При монтировании мы начинаем выборку данных, устанавливаем состояния загрузки и т. Д., И это состояние действительно легко понять.

И 1, и 2, и 1 2 3 4

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

const { Provider, Consumer: CategoriesConsumer } = createContext();
class CategoriesProvider extends Component {
  state = { data: null, loading: false, error: null };
  fetch = async () => {
    this.setState({ loading: true }); 
    try {
      const data = await fetch('/categories');
      this.setState({ data, loading: false });
    } catch (error) { 
      this.setState({ error, loading: false });
    } 
  };
  componentDidMount() {
    this.fetch();
  }
  render () {
    const { children } = this.props;
    return <Provider value={this.state}>{children}</Provider>; 
  }
}
// usage
const CategoriesDropdown = ( 
  <CategoriesConsumer>
    ({ data, loading, error }) => <Dropdown ... />
  </CategoriesConsumer>
)

У нас остались «только» две проблемы: куда поместить <CategoriesProvider /> и что произойдет, если Провайдер по какой-то причине отсутствует сверху дерева. Чтобы справиться со сложностью, связанной с реализацией правильного отката контекста, потребовалось бы еще одно сообщение в блоге, поэтому пока давайте предположим, что поставщик всегда будет отображаться «где-то» (возможно, вместе с другими десятками поставщиков, которые могут вам понадобиться ).

Запрос данных о взаимодействии

Приведенный выше фрагмент может работать, но на самом деле мы не хотим запускать fetch при CategoriesProvider монтировании. Учитывая, что он находится выше в дереве и раскрывающийся список может никогда не открыться, мы хотим быть хорошими гражданами и не заставлять браузер выполнять бесполезный сетевой запрос. Итак, мы решили предоставить функцию fetch напрямую через значение контекста:

render () {
  const value = { ...this.state, fetch: this.fetch };
  return <Provider value={value}>{this.props.children}</Provider>;
}

Пожалуйста, не делайте этого. Можете ли вы определить ошибку и ее последствия? Каждый раз, когда CategoriesProvider выполняет повторный рендеринг, мы собираемся передать новый объект значения. Это вызовет повторную визуализацию всех CategoriesConsumer компонентов независимо от изменения состояния. Мы знаем, что создавать объекты во время рендеринга и передавать их в качестве реквизита не идеально, но в ContextProviders это просто экспоненциально хуже. Как только мы замечаем ошибку (проблема для читателей: как бы вы обнаружили такую ​​ошибку во время выполнения? Как вы можете найти поставщика, повторно отображающего потребителей, когда состояние на самом деле не изменилось?), и мы следуем Рекомендации по контексту React , также сохраняя метод в состоянии:

class CategoriesProvider extends Component {
  constructor() {
    this.state = {
      data: null, loading: false, error: null,
      fetch: this.fetch,
    };
  }
  // other code and render unchanged, 
  // we pass the entire state as context value
}

Теперь мы начинаем реализовывать нашу собственную оболочку CategoriesConsumer, чтобы получать категории, когда потребитель действительно отображается:

const CategoriesQuery = ({ children }) => {
  const context = useContext(CategoriesContext);
  useEffect(
    () => { if (!context.data) context.fetch(); },
    [context], 
  );
  return children(context);
}

Готово (смотри, мама, с крючками!). Однако есть небольшая проблема: когда мы добавляем CategoriesQuery в два разных места нашего дерева рендеринга, если эти два компонента монтируются одновременно, fetch будет вызываться дважды.

Просто небольшая ошибка (это задумано?). Мы не проверяем состояние загрузки перед вызовом fetch. Давайте быстро исправим это в Provider:

// class CategoriesProvider...
fetch = () => {
  if (this.state.loading) return;
  // ...
}

Обновляем страницу, монтируются эти два CategoriesQuery компонента и… БУМ! Еще две просьбы. Оглядываясь назад на наш код ... (WTF!) Проверяем снова и снова ... где-то должно быть что-то не так ... нет, без ошибок ... и внезапно осознание: React setState является асинхронным. Когда наши два компонента монтируются одновременно (поскольку React может пакетировать обновления) loading будет false во время обоих вызовов, даже если setState уже был вызван один раз. Arrrggh !!

Теперь мы можем начать искать более приличный (или менее дерьмовый) способ справиться с асинхронной природой setState в поставщике контекста (вы можете проявить изобретательность и решить проблему в 4–5 разными способами!), но можете убедиться, что не существует «достойного» способа. Всегда хочется бороться с фреймворком.

Какие у нас есть альтернативы?

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

Вот почему в Atlassian мы разработали response-sweet-state, объединив в одном решении то, что мы считаем лучшими частями React Context API и Redux. Перевод такого провайдера в запеченный контейнер состояний прост:

import { createStore, createHook } from 'react-sweet-state';
// define store initial state
const initialState = {
  data: null, 
  loading: false,
  error: null,
};
// define the actions that mutate the state
const actions = {
  fetch: () => async ({ getState, setState }) => {
    if (getState().loading) return;
    setState({ loading: true }); 
    try {
      const data = await fetch('/categories');
      setState({ data, loading: false });
    } catch (error) { 
      setState({ error, loading: false });
    } 
  }
}
// create a store type
const CategoriesStore = createStore({ initialState, actions });
// create components to access store state instances
const useCategories = createHook(CategoriesStore);
// usage
const CategoriesQuery = ({ children }) => {
  const [state, actions] = useCategories();
  useEffect(
    () => { if (!state.data) actions.fetch(); },
    [state, actions], 
  );
  return children(context);
}

С точки зрения кода они почти одинаковы, и под капотом response-sweet-state используется часть Context API, но обеспечивает более безопасную абстракцию: более легко тестируемые действия, поддержка без поставщика из коробки, интеграция с Redux devtools. , защищает вас от некоторых причуд React Context и многого другого. Вы должны попробовать и рассказать нам, что вы думаете!