Управление стейтом с помощью React Hooks – без Redux и Context API

в 14:50, , рубрики: javascript, React, ReactJS, Блог компании ВКонтакте, Разработка веб-сайтов

Всем привет! Меня зовут Артур, я работаю ВКонтакте в команде мобильного веба, занимаюсь проектом VKUI — библиотекой React-компонентов, с помощью которой написаны некоторые наши интерфейсы в мобильных приложениях. Вопрос работы с глобальным стейтом у нас пока открыт. Существует несколько известных подходов: Redux, MobX, Context API. Недавно я наткнулся на статью André 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().

Управление стейтом с помощью React Hooks – без Redux и Context API - 2

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

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('Я запущусь только первый раз');
}, []);

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

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

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

Управление стейтом с помощью React Hooks – без Redux и Context API - 3

Идея состоит в том, чтобы создать массив слушателей и только один стейт. Каждый раз, когда компонент меняет стейт, все подписавшиеся компоненты вызывают свой 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, который тоже идёт из коробки, данный подход уменьшает количество ненужных перерисовок и потому выигрывает в производительности.

Автор: ArthurSupertramp

Источник

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