Как в React избавиться от сложности в управлении состоянием — отчёт по итогам поездки на React Amsterdam

в 8:30, , рубрики: javascript, React, ReactJS, Блог компании NIX Solutions, Программирование

В апреле посчастливилось побывать на очень крутом мероприятии — React Amsterdam. Кроме приятных организационных моментов было ещё и много интересных докладов. Они были, в основном, прикладного характера. Поскольку стек технологий в принципе устоялся, докладчики рассказывали о способах решения практических проблем, а не продвигали что-то незнакомое и революционное. Здесь я расскажу подробнее о выступлении “setState MachineМикеле Бертоли из Facebook.

Основная проблема, которой был посвящен доклад, — сложность в управлении состоянием в React.

Для примера давайте реализуем всем знакомую функциональность — Infinite Scroll, то есть подгрузку данных при прокрутке пользователем до конца (или почти до конца) страницы. Существует много полезных пакетов, которые решают эту задачу, однако же нередко приходится писать самостоятельно.

Что нам для этого потребуется сделать в нашем компоненте:

  1. Добавить обработчик для события scroll.
  2. Добавить проверку, пролистал ли пользователь до нужного места, с которого будем подгружать данные.
  3. Собственно, подгрузить данные.

В первом приближении этого достаточно, однако давайте добавим еще несколько требований для корректной работы:

  1. Не подгружать новые данные, если предыдущая порция еще грузится.
  2. Как-то информировать пользователя о том, что данные грузятся — показать loading или что-то в этом роде на время загрузки.
  3. Не начинать подгрузку данных, если уже всё загружено.
  4. Добавить отображение ошибки пользователю.

Давайте представим, что должно храниться в нашем состоянии, помимо данных, чтобы наша функциональность работала правильно:

  1. Флаг isFetching — показывает нам, что сейчас грузятся данные.
  2. Поле error — должно содержать информацию об ошибке.
  3. Поле isEmpty — показывает нам, что данных нет.
  4. Если вдруг мы захотим добавить функциональность для retry, то нужно хранить информацию и для неё.

Какие основные недостатки у такой реализации:

  1. Большая привязка к контексту. Очень много условий, например, грузим данные только тогда, когда прокрутили до нужного места, при этом не грузятся предыдущие данные, и т.д.
  2. Наш код сложно читать и понимать.
  3. Сложно масштабировать — при добавлении нового свойства в состояние нужно пройтись по всем нашим условиям и понять, как нужно менять состояние в том или ином месте, чтоб не сломать логику. Это также может привести к багам.

Исправить все эти недостатки нам поможет машина состояний (State Machine).

По сути, это принцип использования конечного автомата состояний (Final State Machine), некой абстрактной модели, содержащей конечное число состояний.

Описывается модель при помощи пяти параметров:

  1. Все состояния, в которых может находится автомат.
  2. Набор всех входных данных, принимаемых автоматом.
  3. Функция переходов — принимает предыдущее состояние и набор входных данных, возвращает новое состояние.
  4. Начальное состояние.
  5. Конечное состояние.

В каждый момент времени активным может быть лишь одно состояние.

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

Давайте добавим в наш код библиотеку react-automata — абстракцию машины состояний для React. Это обертка над еще одной библиотекой xstate — функциональные JS-машины состояний без сохранения состояния и диаграммы состояний.

Чтобы понять, как эта теория применима к нашему случаю, давайте посмотрим, как функциональность будет выглядеть в виде statechart:

Как в React избавиться от сложности в управлении состоянием — отчёт по итогам поездки на React Amsterdam - 1

Для начала укажем начальное состояние — точку входа, которой является добавление события прокрутки. Когда мы готовы к дальнейшей работе, посылаем машине событие READY, и машина переходит в состояние загрузки. Если загрузка была успешной и пока не все данные загружены, мы переходим в состояние прослушивания. Когда при прокрутке выполняется условие подгрузки новой порции данных, мы переходим в состояние загрузки и можем пребывать в этом цикле, пока не загрузим все данные. Тогда мы больше не слушаем событие.

Схематично наш код может выглядеть так:

import React from 'react'
import { hot } from 'react-hot-loader'
import { Action, withStatechart } from 'react-automata'

export const statechart = {
	// начальное состояние
	initial: 'attach',
	// Список состояний
	states: {
		attach: {
			on: {
				READY: 'fetching',
			},
		},
		fetching: {
			on: {
				SUCCESS: {
					listening: {
						//Переходим в состояние listening по событию SUCCESS
						//cond - pure функция, переводящая машину в указанное состояние, если возвращает правдивое значение
						cond: extState => extState.hasMore,
					},
					detach: {
						cond: extState => !extState.hasMore,
					},
				},
				ERROR: 'listening',
			},
			// fetch - событие, которое должно быть выполнено при входе в состояние fetching
			onEntry: 'fetch',
		},
		listening: {
			on: {
				SCROLL: 'fetching',
			},
		},
		detach: {},
	},
}

class InfiniteScroll extends React.Component {
	componentDidMount() {
		// на mount нашего компонента переходим из начального состояния в fetching
		this.attach()
	}

attach() {
	//навешиваем наш обработчик и переходим в состояние fetching
	//возможна, конечно, и другая реализация этого перехода - зависит от требований к работе фичи
	this.element.addEventListener('scroll', this.handleScroll)
	this.props.transition('READY')
}

handleScroll = e => {
	const { scrollTop, scrollHeight, clientHeight } = e.target

	const isCilentAtBottom = 0.9 * (scrollHeight - scrollTop) === clientHeight

	if (isCilentAtBottom) {
		// Переход из listening в fetching
		this.props.transition('SCROLL')
	}
}

fetch() {
	const { transition } = this.props

	loadTodos()
		.then(res => res.json())
		.then(data => transition('SUCCESS', { todos: data }))
		.catch(() => transition('ERROR'))
}

render() {
	// Action - компонент, который определяет, что должно рендериться для данного события
	return (
		<div

			ref={element => {
				this.element = element
			}}
		>
			<Action show="fetch">Loading...</Action>
			<ul>
				{this.props.todos.map(todo => <li key={todo.id}>{todo.text}</li>)}
			</ul>
		</div>
	)
	}
}

InfiniteScroll.defaultProps = {
	todos: [],
}

const initialData = { todos: [], devTools: true }

const StateMachine = withStatechart(statechart, initialData)(InfiniteScroll)

export default hot(module)(StateMachine)

Мы используем hoc withStatechart из react-automata, передаем наши начальные данные, и теперь в props доступны метод transition для изменения состояния машины, и machineState — текущее состояние машины.

Переменная statechart — это программное описание нашего рисунка.

Преимущества подхода:

  1. Меньше багов.
  2. Проще читать и понимать код.
  3. Разделение того, что случилось и когда случилось. Первое управляется компонентом, второе — диаграммами состояний.

Полезные ссылки:

  1. Доклад Микеле Бертоли на React Amsterdam 2018: https://www.youtube.com/watch?v=smBND2pwdUE&t=3137s
  2. React Automata: https://github.com/MicheleBertoli/react-automata
  3. Документация по xstate: http://davidkpiano.github.io/xstate/docs/#/
  4. Объяснение диаграмм состояний: http://www.inf.ed.ac.uk/teaching/courses/seoc/2005_2006/resources/statecharts.pdf

Автор: NIX_Solutions

Источник


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