Перевод статьи Jean-Paul Delimat: Boost your React with State Machines.
Использование React совместно с конечными автоматами - замечательный способ для повышения вашей продуктивности как разработчика, также улучшающий часто шаткие взаимоотношения разработчиков/дизайнеров.
Идея конечного автомата очень проста: компонент может находиться в одном из состояний, число которых ограничено.
Но как это может быть полезно при разработке интерфейсов?
Рассмотрим простой компонент редактирования текста:
Возможные "состояния" такого компонента (слева направо):
- Отображение значения
- Редактирование значения
- Отображение состояния при сохранении
- Отображение ошибки, возникшей при сохранении
В простейшем случае состояние такого компонента может быть описано 5 свойствами:
state: {
processing: true, // Будет true в процессе сохранения
error: null, // Будет не null когда возникла ошибка сохранения
value: null, // Значение для отображения (только для чтения)
edition: false, // Находимся ли мы в режиме редактирования?
editValue: null, // Отредактированное, но ещё не сохраненное значение
}
Правильная комбинация этих свойств выдаст нам одно из 4 состояний, изображенных выше.
Проблема состоит в том, что на самом деле из этих свойств можно получить 2⁵ = 32 возможных состояний компонента. Таким образом, существует 28 неправильных способов использовать эти свойства.
Одна из распространенных ошибок при реализации таких компонентов - отсутствие удаления ошибки после успешного сохранения. Пользователь попробует сохранить поле, увидит сообщение об ошибке, исправит её, сохранит опять и попадёт в состояние отображения значения. Всё хорошо, но как только он снова переключится в состояние редактирования значения... ошибка всё еще будет отображаться. Бывает. Я много раз видел, как такие ошибки допускали менее опытные разработчики.
Несмотря на то, что наш компонент достаточно прост, он раскрывает проблему такого подхода:
Оперирование исходными свойствами состояния означает, что устойчивость состояния целиком зависит от корректного использования этих состояний... каждым разработчиком, который будет изменять этот код... на протяжении всей жизни проекта.
Все мы знаем, к чему это приводит!
Рассмотрим другой подход с использованием "конечных автоматов". Состояния будут такими:
state: {
display: {
processing: false,
error: null,
value: "Awesome",
edition: false,
editValue: null,
},
saving: {
processing: true,
error: null,
value: "Awesome",
edition: true, // Оставляем режим редактирования открытым, пока значение сохраняется
editValue: "Awesome Edit",
},
edit: {
processing: false,
error: null,
value: "Awesome",
edition: true,
editValue: "Awesome Editing",
},
save_error: {
processing: false,
error: "Значение должно быть не короче 4 символов",
value: "Awesome",
edition: true, // Оставляем окно редактирования открытым
editValue: "Awe",
}
}
Получилось более многословно, однако у такого подхода есть ряд преимуществ:
- Легко увидеть все состояния компонента, просто взглянув на конечный автомат. Состояния имеют логичные названия и каждое свойство самозадокументированно. Новые разработчики в команде почувствуют себя как дома.
- Легко понять, как расширять компонент: создаём новое состояние и выставляем соответствующие свойства. Никто в здравом уме не станет использовать обычный
setState()
, когда такой подход реализован в компоненте. - Последнее, но не менее важное: взаимодействие с дизайнером становится простым, насколько это возможно. Вам необходим только макет для каждого из состояний, и, может быть, анимации для переходов. И всё.
Минимальная рабочая версия примера выше могла бы выглядеть так:
import React, {Component, PropTypes} from 'react';
export default class InputStateMachine extends Component {
constructor(props) {
super(props);
this.handleSubmit = this.handleSubmit.bind(this);
this.goToState = this.goToState.bind(this);
this.save = this.save.bind(this);
this.state = {
name: 'display',
machine: this.generateState('display', props.initialValue),
};
}
generateState(stateName, stateParam) {
const previousState = this.state ? {...this.state.machine} : {};
switch (stateName) {
case 'display':
return {
processing: false,
error: null,
value: stateParam || previousState.value,
editing: false,
editValue: null,
};
case 'saving':
return {
processing: true,
error: null, // Сброс предыдущей ошибки сохранения
value: previousState.value,
editing: true, // Отображение окна редактирования в процессе сохранения
editValue: previousState.editValue,
};
case 'edit':
return {
processing: false,
error: null,
value: previousState.value,
editing: true,
editValue: stateParam,
};
case 'save_error':
return {
processing: false,
error: stateParam,
value: previousState.value,
editing: true, // Оставляем окно редактирования открытым
editValue: previousState.editValue,
};
case 'loading': // Идентично состоянию по умолчанию
default:
return {
processing: true,
error: null,
value: null,
editing: false,
editValue: null,
};
}
}
goToState(stateName, stateParam) {
this.setState({
name: stateName,
machine: this.generateState(stateName, stateParam),
});
}
handleSubmit(e) {
this.goToState('edit', e.target.value);
}
save(valueToSave) {
this.goToState('saving');
// Имитируем сохранение данных...
setTimeout(() => this.goToState('display', valueToSave), 2000);
}
render() {
const {processing, error, value, editing, editValue} = this.state.machine;
if (processing) {
return <p>Processing ...</p>;
} else if (editing) {
return (
<div>
<input
type="text"
onChange={this.handleSubmit}
value={editValue || value}
/>
{error && <p>Error: {error}</p>}
<button onClick={() => this.save(editValue)}>Save</button>
</div>
);
} else {
return (
<div>
<p>{value}</p>
<button onClick={() => this.goToState('edit', value)}>Edit</button>
</div>
);
}
}
}
Использование такого компонента:
<InputStateMachine initialValue="Hello" />
При работе с конечными автоматами приходится писать немного шаблонного кода:
- Создайте утилитарный метод, который будет задавать название состояния и его содержимое. Позволяет легко получить текущее состояние и упрощает отладку компонента.
- Сохраняйте метод, генерирующий состояние компонента, чистым и используйте его для генерации изначального состояния в конструкторе.
- Используйте при деструктуризации
this.state.machine
вместоthis.state
в вашем методеrender
. - Состоянию иногда необходимы параметры. Как правило, если ваше состояние требует более 3 параметров, вам не стоит использовать конечные автоматы в этом компоненте.
Некоторые библиотеки решают проблему дополнительного кода, но его так мало, что они вряд ли заслуживают место в зависимостях вашего проекта.
Конечные автоматы — хороший способ улучшения читаемости ваших компонентов и процесса разработки этих компонентов от дизайна до поддержки.
Однако будьте осторожны! Не стоит использовать этот подход на всех компонентах! Ваше приложение должно оставаться гибким и обрабатывать непредвиденные сложности. Количество состояний для компонентов высокого уровня может быстро возрасти и такой подход не даст никакой пользы.
Несмотря на это, используйте такой подход при разработке вашей библиотеки стандартных/базовых компонентов! Это основа вашего приложения. Рано или поздно каждый разработчик в команде будет с ней работать и сможет на себе ощутить пользу конечных автоматов.
Спасибо за прочтение!
Слушайте наш подкаст в iTunes и SoundCloud, читайте нас на Medium, контрибьютьте на GitHub, общайтесь в группе Telegram, следите в Twitter и канале Telegram, рекомендуйте в VK и Facebook.