Всем привет! Меня зовут Артур, я работаю ВКонтакте в команде мобильного веба, занимаюсь проектом VKUI — библиотекой React-компонентов, с помощью которой написаны некоторые наши интерфейсы в мобильных приложениях. Вопрос работы с глобальным стейтом у нас пока открыт. Существует несколько известных подходов: Redux, MobX, Context API. Недавно я наткнулся на статью Andre Gardi State Management with React Hooks?—?No Redux or Context API, в которой автор предлагает использовать React Hooks для управления стейтом приложения.

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

image

React Hooks мощнее, чем вы думаете


Сегодня мы изучим React Hooks и разработаем кастомный хук для управления глобальным стейтом приложения, который будет проще Redux-реализации и производительнее Context API.

Основы React Hooks


Можете пропустить эту часть, если уже знакомы с хуками.

useState()


До появления хуков у функциональных компонентов не было возможности задавать локальный стейт. Ситуация изменилась с появлением useState().



Данный вызов возвращает массив. Первым его элементом является переменная, предоставляющая доступ к значению стейта. Второй элемент — это функция, которая обновляет стейт и перерисовывает компонент для отражения изменений.

import React, { useState } from 'react';

function Example() {
  const [state, setState] = useState({counter:0});
  const add1ToCounter = () => {
    const newCounterValue = state.counter + 1;
    setState({ counter: newCounterValue});
  }

  return (
    <div>
      <p>You clicked {state.counter} times</p>
      <button onClick={add1ToCounter}>
        Click me
      </button>
    </div>
  );
}

useEffect()


Классовые компоненты реагируют на сайд-эффекты, используя lifecycle-методы, такие как componentDidMount(). Хук useEffect() позволяет делать то же самое в функциональных компонентах.

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

// Вызов без второго параметра
useEffect(() => {
  console.log('Я буду запускаться после каждого рендера');
});

// Со вторым параметром
useEffect(() => {
  console.log('Я вызовусь только при изменении valueA');
}, [valueA]);

Чтобы достичь результата, аналогичного componentDidMount(), мы передадим пустой массив вторым параметром. Так как содержимое пустого массива всегда остаётся неизменным, эффект выполнится лишь один раз.

// Вызов с пустым массивом
useEffect(() => {
  console.log('Я запущусь только первый раз');
}, []);

Шаринг состояния


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

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



Идея состоит в том, чтобы создать массив слушателей и только один стейт. Каждый раз, когда компонент меняет стейт, все подписавшиеся компоненты вызывают свой getState() и за счёт этого обновляются.

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

Погодите. Как это упростит мне жизнь?


Да, вы правы. Я создал NPM-пакет, инкапсулирующий всю описанную логику.

Вам не придётся реализовывать её в каждом проекте. Если вы больше не хотите тратить время на чтение и желаете посмотреть на финальный результат, просто добавьте этот пакет в ваше приложение.

npm install -s use-global-hook

Чтобы понять, как работать с пакетом, изучите примеры в документации. А сейчас предлагаю сфокусироваться на том, как пакет устроен внутри.

Первая версия


import { useState, useEffect } from 'react';

let listeners = [];
let state = { counter: 0 };

const setState = (newState) => {
  state = { ...state, ...newState };
  listeners.forEach((listener) => {
    listener(state);
  });
};

const useCustom = () => {
  const newListener = useState()[1];
  useEffect(() => {
    listeners.push(newListener);
  }, []);
  return [state, setState];
};

export default useCustom;

Использование в компоненте


import React from 'react';
import useCustom from './customHook';

const Counter = () => {
  const [globalState, setGlobalState] = useCustom();

  const add1Global = () => {
    const newCounterValue = globalState.counter + 1;
    setGlobalState({ counter: newCounterValue });
  };

  return (
    <div>
      <p>
        counter:
        {globalState.counter}
      </p>
      <button type="button" onClick={add1Global}>
        +1 to global
      </button>
    </div>
  );
};

export default Counter;

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

Но мы можем лучше


Чего хочется:

  • удалять слушателя из массива при размонтировании компонента;
  • сделать хук более абстрактным, чтобы использовать его в других проектах;
  • управлять initialState с помощью параметров;
  • переписать хук в более функциональном стиле.

Вызов функции прямо перед размонтированием компонента


Мы уже выяснили, что вызов useEffect(function, []) с пустым массивом работает так же, как componentDidMount(). Но если функция, переданная в первом параметре, возвращает другую функцию, то вторая функция будет вызвана прямо перед размонтированием компонента. В точности как componentWillUnmount().

Значит, в коде второй функции можно написать логику удаления компонента из массива слушателей.

const useCustom = () => {
  const newListener = useState()[1];
  useEffect(() => {
    // Вызывается сразу после монтирования
    listeners.push(newListener);
    return () => {
      // Вызывается прямо перед размонтированием
      listeners = listeners.filter(listener => listener !== newListener);
    };
  }, []);
  return [state, setState];
};

Вторая версия


Помимо этого обновления мы также планируем:

  • передавать React параметром и избавиться от импорта;
  • экспортировать не customHook, а функцию, возвращающую customHook с заданным initalState;
  • создать объект store, который будет содержать значение state и функцию setState();
  • заменить arrow-функции обычными в setState() и useCustom(), чтобы можно было связать store с this.

function setState(newState) {
  this.state = { ...this.state, ...newState };
  this.listeners.forEach((listener) => {
    listener(this.state);
  });
}

function useCustom(React) {
  const newListener = React.useState()[1];
  React.useEffect(() => {
    // Вызывается сразу после монтирования
    this.listeners.push(newListener);
    return () => {
      // Вызывается прямо перед размонтированием
      this.listeners = this.listeners.filter(listener => listener !== newListener);
    };
  }, []);
  return [this.state, this.setState];
}

const useGlobalHook = (React, initialState) => {
  const store = { state: initialState, listeners: [] };
  store.setState = setState.bind(store);
  return useCustom.bind(store, React);
};

export default useGlobalHook;

Отделяем экшены от компонентов


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

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

Для этого снабдим наш useGlobalHook(React, initialState, actions) третьим аргументом. Сразу хочется добавить пару замечаний.

  • Экшены будут иметь доступ к store. Таким образом, экшены смогут читать содержимое store.state, обновлять стейт вызовом store.setState() и даже вызывать другие экшены, обращаясь к store.actions.
  • Во избежание каши, объект экшенов может содержать подобъекты. Таким образом, вы можете перенести actions.addToCounter(amount) в подобъект со всеми экшенами счетчика: actions.counter.add(amount).

Финальная версия


Следующий сниппет является актуальной версией NPM пакета use-global-hook.

function setState(newState) {
  this.state = { ...this.state, ...newState };
  this.listeners.forEach((listener) => {
    listener(this.state);
  });
}

function useCustom(React) {
  const newListener = React.useState()[1];
  React.useEffect(() => {
    this.listeners.push(newListener);
    return () => {
      this.listeners = this.listeners.filter(listener => listener !== newListener);
    };
  }, []);
  return [this.state, this.actions];
}

function associateActions(store, actions) {
  const associatedActions = {};
  Object.keys(actions).forEach((key) => {
    if (typeof actions[key] === 'function') {
      associatedActions[key] = actions[key].bind(null, store);
    }
    if (typeof actions[key] === 'object') {
      associatedActions[key] = associateActions(store, actions[key]);
    }
  });
  return associatedActions;
}

const useGlobalHook = (React, initialState, actions) => {
  const store = { state: initialState, listeners: [] };
  store.setState = setState.bind(store);
  store.actions = associateActions(store, actions);
  return useCustom.bind(store, React);
};

export default useGlobalHook;

Примеры использования


Вам больше не придётся иметь дело с useGlobalHook.js. Теперь вы можете сфокусироваться на вашем приложении. Ниже представлены два примера использования пакета.

Несколько счётчиков, одно значение


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

Асинхронные ajax-запросы


Поиск GitHub-репозиториев по имени пользователя. Обрабатываем ajax-запросы асинхронно с помощью async/await. Обновляем счетчик запросов при каждом новом поиске.
Живой пример.

Ну вот и всё


Теперь у нас есть собственная библиотека по управлению стейтом на React Hooks.

Комментарий переводчика


Большинство существующих решений — по сути, отдельные библиотеки. В этом смысле подход, описанный автором, интересен тем, что в нём используются только встроенные возможности React. Кроме того, по сравнению с тем же Context API, который тоже идёт из коробки, данный подход уменьшает количество ненужных перерисовок и потому выигрывает в производительности.

Комментарии (18)


  1. polRk
    31.05.2019 17:58
    +1

    Было бы хорошо начать использовать typescript ибо это накладывает большие ограничения на использование библиотеки VKUI, которой до сих пор нельзя пользоваться.


    1. ArthurSupertramp Автор
      01.06.2019 12:04

      ВКонтакте есть подвижки на тему TS, поэтому есть вероятность, что в VKUI он тоже появится. К сожалению, точнее пока сказать не могу.


  1. nshkaruba
    31.05.2019 18:04

    Топ!


  1. Staltec
    31.05.2019 19:28
    +4

    А теперь, посмотрите на код приведённый в примерах и спросите себя: «Я действительно хочу поддерживать этот треш годами?».


    1. Vasily_T
      01.06.2019 12:46

      Что Вы имеете ввиду? Это ж пара небольших примеров,
      в комментах к оригинальной статье автор утверждает что у него производительность лучше, других плюсов не приводит


  1. bex2014
    01.06.2019 16:01
    +2

    Этих статей уже куча… и оказывается, что хуки это еще не то, что может заменить redux, что надо писать еще свои дополнения. Уже несколько таких реализаций есть. Вообщем опять будет зоопарк из этих кастомных дополнений.

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


    1. ArthurSupertramp Автор
      01.06.2019 16:03

      А зачем останавливаться?) Я в одном из своих проектов до сих пор использую Redux, в другом – вообще ничего не юзаю, кроме state и props, потому что приложение маленькое. По-моему это классно, когда люди придумывают новые подходы, особенно если их идеи снабжены аргументацией.


    1. gnaeus
      01.06.2019 22:50
      +1

      Более того, как выяснилось впоследствии, хуки и контекст в текущем виде не могут полностью заменить Redux. Потому что useContext() перерисовывает компонент при любом изменении значения в Context.Provider. И подписаться только на интересующие нас части состояния как в mapStateToProps невозможно. Приходится писать свой кастомный HOC по типу connect из react-redux. Что убивает саму идею хуков для управления состоянием относительно сложных приложений. Хотя для простых сойдет.


      1. AZaz1
        03.06.2019 03:04

        более того выясняется что хуки и контекст в текущем виде МОГУТ полностью заменить Redux и делают это нужно лишь прочитать доки и ознакомиться основными методами оптимизации
        https://github.com/facebook/react/issues/15156#issuecomment-474590693


        1. faiwer
          03.06.2019 09:44

          По ссылке 2 обёртки и 1 костыль. По сути там предлагается ровно то что делает connect: Memo + HoC (вариант 2) + context. О чём выше и написал gnaeus. Вариант 3 там видимо шутки ради указан :)


          1. AZaz1
            03.06.2019 12:13

            ну если Абрамов для вас костыли пишет ...!!!
            тогда уж не знаю что вам сказать ))))


            1. faiwer
              03.06.2019 12:35

              Я не состою в священной церкви Абрамова. И вариант номер 3 это самый настоящий костыль (я уж молчу про то, что хуки сами по себе похожи на тот ещё костыль). Такое не должно проходить код-ревью.


              P.S. Я понимаю когда "лебезят" перед действительно выдающимися программистами, но камон, причём тут Абрамов. Он просто публичная фигура от команды React, не более.


              1. AZaz1
                03.06.2019 13:30

                у нас реализован редакс на хуках и с помощью данных «костылей» — все отлично работает без какого-либо геморроя и лишних перерисовок

                если вы не в курсе — редакс вообще состоит из одних костылей но тем не менее его успешно все его используют

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

                И 3й вариант таки самый замечательный со всех точек зрения — инкапсулирует в себе логику получения данных и мемоизацию рендера — все в одном методе — не нужно плодить лишних сущностей

                Выражаясь вашим языком — вся индустрия IT — это сплошной костыль к нашей бренной жизни :)


                1. faiwer
                  03.06.2019 18:25

                  Присмотрелся внимательнее к 3-му варианту. Признаю, что был неправ, т.к. прочитал тот код по диагонали и воспринял третий вариант как объединение контейнера, вьюхи и мемоизации в одной функции. А там всё-таки вьюха отдельно (<ExpensiveTree className={theme} />).


                  у нас реализован редакс на хуках и с помощью данных «костылей» — все отлично работает без какого-либо геморроя и лишних перерисовок

                  Стесняюсь спросить, а зачем вы его руками на хуках написали? Что именно в стандартном connect вас не устроило так сильно, что сподвигло вас на велосипед?


                  но тем не менее его успешно все его используют

                  шутка про wordpress & drupal & joomla & ...
                  P.S. мне one-way подход нравится, но всё таки успешность слабый аргумент.


                  1. AZaz1
                    03.06.2019 18:41
                    +1

                    мы разработали свой редакс по следующим причинам:

                    — мы перешли на hooks и стандартный redux с ними не очень дружит (маинтейнер делал недавно доклад и рассказывал какие они костыли и танцы с бубном применяют чтобы синхронизироваться с жизненным циклом hooks)
                    — стандартный redux стал медленным из-за вышеописанных проблем (там под капотом создается класс который следит за подписками переподписками родителей и детей и переопределяет их порядок чтобы подписка родителя всегда вызывалась раньше чем у детей) и довольно много весит и есть нормальная альтернатива с контекстом и useRedux
                    — самое главное: у redux есть большая проблема — stale props и zombie children — react-redux.js.org/next/api/hooks#stale-props-and-zombie-children

                    альтернатива весит вообще ничего и нас полностью устраивает своей простотой и скоростью работы

                    возможно осенью будет доклад на #ReactRussia если тема будет интересна широкой аудитории


                    1. faiwer
                      03.06.2019 19:12

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

                      О я на эти грабли натыкался. Пару лет назад этого не было и я ловил адовые глюки на этой почве. Когда у детей падал mapStateToProps, до того как React успевал их убить.


                      stale props и zombie children

                      thx, почитаю.


                      возможно осенью будет доклад на #ReactRussia если тема будет интересна широкой аудитории

                      Про широкую аудиторию не скажу, но лично мне был бы интересно послушать.


  1. gnaeus
    01.06.2019 22:40
    +1

    Кроме того, по сравнению с тем же Context API, который тоже идёт из коробки, данный подход уменьшает количество ненужных перерисовок и потому выигрывает в производительности.

    А откуда в Context API лишние перерисовки по сравнению с подходом из статьи?
    Возьмем для примера, как это реализовано в библиотеке constate:


    1. Пишем произвольный кастомный хук с useState / useReducer
    2. Распространяем его состояние вниз по дереву компонентов с помощью Context.Provider
    3. Подписываемся на изменения состояния с помощью useContext

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


    1. amakhrov
      03.06.2019 00:47

      Я бы даже добавил, что подход в статье вызывает перерисовку всех-всех подписанных компонентов на каждое изменение стейта — неважно, что там изменилось.