State Machine: як швидко і зручно організувати код
Привіт, я Дмитро Хаджанов, Front-End developer в компанії MOJAM з 10 роками роботи в IT. На моєму досвіді State Machine — один з найпростіших, але найефективніших інструментів організації коду в компонентах. Але попри це я не можу стверджувати, що інструмент популярний в Front-End спільноті.
Я брав участь в розробці щонайменше п’яти великих проєктів, і лише в одному бачив його систематичне використання. Частка розробників, з якими я спілкувався, і котрі мали такий досвід, також залишає бажати кращого. Тож ціллю статті є розповісти більше про State Machine і поширити знання, які будуть корисними для його практичного застосування.
Що ж таке State Machine
Як не дивно, це саме той випадок, коли нам для розуміння концепції можуть знадобитись знання математики. Основою цього інструменту є теорія скінченних автоматів. Вона описує всі можливі стани системи та переходи між ними. При цьому система може бути будь-якої складності: від фізичних явищ до інженерних засобів і програмного забезпечення.
Для зображення такої системи часто малюють діаграму станів. Вершини, вони ж «стани», позначаються овалом або прямокутником. Ребра, вони ж «переходи між станами», позначаються стрілочками. В принципі цього вже достатньо, але також можна використовувати правила UML State Machine Diagram.
Як приклад можна зобразити діаграму станів переходу води в різні агрегатні стани:

Хоча ця діаграма і неточна, вона показує декілька основних правил:
- Вода може мати тільки три агрегатні стани.
- Перехід між станами відбувається тільки при виконанні певних дій/умов.
- Більше жодних переходів між станами не існує, тобто вода не може з льоду відразу стати паром, не перетворившись на воду.
Основне і найцінніше, що можна винести з цієї схеми — одночасно вода може перебувати лише в одному стані. Цей стан, незалежно від того, як він зберігається (в пам’яті системи чи у вигляді фізичного явища) є єдиним джерелом істини, на яке ми можемо посилатись.
Не зайвим буде зазначити, що існує GoF-патерн state, що базується на цій самій ідеї, але інкапсулює в об’єктах станів корисні дані\методи, унікальні для кожного стану. Тобто State Machine перш за все концентрує цю ідею. Прочитати детальніше можна на ресурсі guru refactoring. Пізніше ми розберемо приклад застосування цього патерну в контексті вебзастосунку.
Застосування State Machine на практиці
Тож яким чином ці принципи можуть допомоги в розробці вебзастосунків? Для прикладу можемо розглянути типову сторінку каталогу продуктів. Спершу здається, що там небагато станів. Одразу можна виділити стани «завантаження», «відображення»... можливо ще й «помилки». І найтиповіше рішення, яке імплементує більшість розробників — просто створити декілька флагів.
Розглянемо приклад (на Vue, але це не принципово):
<template>
<div class="list-container">
<div class="list" v-if="products.length > 0">
<Product v-for="product in products" :product="product" />
</div>
<Loader v-if="isLoading" />
</div>
</template>
<script>
const isLoading = ref(true)
const products = ref([])
onMounted(async () => {
try {
products.value = await getProducts()
} catch (e) {
alert(e.message)
} finally {
isLoading = false
}
})
</script>
Можемо бачити досить просту логіку відображення компонента і завантаження даних. Проте вже є на що звернути увагу:
- Компонент по факту може перебувати в декількох станах одночасно, тому що за це відповідають різні флаги/дані. Для прикладу досить можливо, що в майбутньому, хтось змінить код — і в нас виникне можливість отримати і масив продуктів, і лоадер.
- В нас декілька джерел істини, які розробник контролює вручну. При розширенні компонента є велика ймовірність припуститися помилки.
- Недопрацьований флоу ініціалізації. По суті, все, що буде бачити користувач, — лоадер на білому фоні.
Попри те, що в існуючому варіанті є багато недоліків, вони ще яскравіше проявляються з часом при розширенні компонента. Давайте для прикладу розширемо компонент, скажімо, додамо кілька дрібниць:
- Якщо компонент завантажується вперше (ініціалізується):
- Замість лоадера можна показувати текст «дані завантажуються».
- Якщо під час завантаження сталась помилка — показуємо її замість тексту «дані завантажуються» з кнопкою «повторити спробу».
- Додамо фільтрацію.
- Під час фільтрації з’являється лоадер.
- Якщо у результаті фільтрації немає продуктів для демонстрації — показуємо текст «немає продуктів за заданим фільтром» і кнопку «скинути фільтри».
- Якщо під час фільтрації сталась помилка — показуємо алерт.
Після внесення змін у нас буде щось типу такого:
<template>
<div class="list-container">
<div class="list" v-if="products.length > 0">
<Product v-for="product in products" :product="product" />
</div>
<Loader v-if="isLoading && !isIniting" />
<InitingMessage v-if="isLoading && isIniting" />
<Filters v-if="!isIniting" :disabled="isLoading" />
<ErrorMessage v-if="errorMessage.length > 0 && isIniting">{{errorMessage}}</ErrorMessage>
</div>
</template>
<script>
const isLoading = ref(false)
const isIniting = ref(true)
const errorMessage = ref('')
const products = ref([])
const filters = ref({})
const loadProducts = async (filters) => {
try {
isLoading.value = true
products.value = await getProducts(filters)
} finnaly {
isLoading.value = false
}
}
const filter = async () => {
try {
await loadProducts(filters.value)
} catch (e) {
alert(e)
}
}
const init = async () => {
try {
errorMessage.value = ''
await loadProducts()
isIniting.value = false
} catch (e) {
errorMessage.value = e.message
}
}
onMounted(init)
</script>
Прошу звернути увагу, наскільки в цьому прикладі стало важче контролювати й передбачати стан компонента. Я намагався організувати код якнайкраще, але перевірки в темплейті все одно надзвичайно ускладнилися. До того ж якщо в компоненті будуть продукти та флаг isIniting буде в положенні true, то компонент зовсім зламається і поводитиметься непередбачувано.
Як я писав в теоретичній частині, такі проблеми виникають через велику кількість джерел істини. Ми «обмазуємось» флагами станів, які мусимо контролювати в кожній функції, що виконує якусь дію. Цими флагами стає «обмазаний» код, і чим довше існує компонент, тим складніше стає його контролювати.
Можемо спершу використати одне джерело істини як просте рішення, від якого і буде залежати стан компонента:
<template>
<div class="list-container">
<div v-if="state === State.Init">Loading will start in a moment</div>
<div v-if="state === State.Ready" class="list">
<Product v-for="product in products" :product="product" />
</div>
<Loader v-if="state === State.Loading" />
<InitingMessage v-if="state === State.Initing" />
<Filters v-if="[State.Initing, State.Ready, State.Loading].includes(state)" :disabled="state === State.Loading" />
<ErrorMessage v-if="state === State.InitingError">{{ errorMessage || 'Something went wrong'}}</ErrorMessage>
</div>
</template>
<script>
enum State {
Init,
Initing,
InitingError,
Ready,
Loading
}
const errorMessage = ref('')
const state = ref(State.Init)
const products = ref([])
const filters = ref({})
const loadProducts = async (filters) => {
products.value = await getProducts(filters)
}
const filter = async () => {
try {
state.value = State.Loading
await loadProducts(filters.value)
} catch (e) {
alert(e)
} finish {
state.value = State.Ready
}
}
const init = async () => {
try {
state.value = State.isIniting
errorMessage.value = ''
await loadProducts()
state.value = State.Ready
} catch (e) {
state.value = State.InitingError
errorMessage.value = e.message
}
}
onMounted(init)
</script>
Як можна побачити, тепер в структурі компонента немає неочевидних умов. Компонент має перелік станів, в яких може перебувати, і кожен стан чітко описаний. Якщо використати старий-добрий патерн «модуль», то можна трішки «причесати» подібне рішення:
<div class="list-container">
<div v-if="state.is(State.Init)">Loading will start in a moment</div>
</div>
// -------------------
const state = (() => {
const currentState = ref(State.Init)
return {
go(to: State) { currentState.value = to },
is(check: State) { currentState.value === check }
}
})()
const filter = async () => {
try {
state.go(State.Loading)
await loadProducts(filters.value)
} catch (e) {
alert(e)
} finish {
state.go(State.Ready)
}
}
І це варіант, який я наполегливо рекомендую використовувати в більшості випадків. Дуже простий код, передбачуваний, надзвичайно читабельний, легко дебажити й підтримувати. Але це буде працювати лише для невеликих компонентів. Можливо, для якоїсь таблиці, форми, максимум — невеликої сторінки.
Це зумовлено тим, що стани контролює розробник ззовні, а не машина зсередини. Через це у нас немає механізму, що перешкоджатиме некоректним переходам станів. Тобто, якщо зобразити поточні стани компонента і їх можливі переходи на діаграмі, ми матимемо щось типу:

Дуже нагадує малюнок з моєї попередньої статті «навіщо нам DI», і це не дивно, адже причини ті самі, серед яких — порушення принципу інкапсуляції.
Змінити це можна, якщо передати керування станами напряму стейт-машині. Щоб це зробити, ми маємо повністю описати структуру нашого графа (його вершини й ребра), а також умови для переходу між ними. Для створення прикладу можемо використати одне з багатьох уже розроблених рішень: xstate, js-state-machine, typescript-fsm та інших. Але в нашому випадку ми будемо використовувати інтерфейс стейт-машини, яку я розробив сам (за деталями — прошу в особисті повідомлення). Лише зазначу, що я дуже рекомендую виносити такі штуки з компонента. На прикладі побачите, чому:
// component.vue
<template>
<div class="list-container">
<div v-if="stateMachine.is(State.Init)">Loading will start in a moment</div>
<div v-if="stateMachine.is(State.Ready)" class="list">
<Product v-for="product in products" :product="product" />
</div>
<Loader v-if="stateMachine.is(State.Loading)" />
<InitingMessage v-if="stateMachine.is(State.Initing)" />
<Filters v-if="stateMachine.is([State.Initing, State.Ready, State.Loading])" :disabled="stateMachine.is(State.Loading)" />
<ErrorMessage v-if="stateMachine.is(State.InitingError)">{{ errorMessage || 'Something went wrong'}}</ErrorMessage>
</div>
</template>
<script>
const errorMessage = ref('')
const products = ref([])
const filters = ref({})
const loadProducts = async (filters) => {
products.value = await getProducts(filters)
}
const filter = () => {
stateMachine.go(Transaction.Filter)
}
const init = () => {
stateMachine.go(Transaction.Init)
}
const stateMachine = createStateMachine (
loadProducts, // init
() => loadProducts(filters.value), // filter
(message) => errorMessage.value = message // setInitError
)
onMounted(init)
</script>
// component.state-machine.ts
import StateMachine from "my-state-machine"
export enum State {
Init = 'Init',
Initing = 'Initing',
InitingError = 'InitingError',
Ready = 'Ready',
Loading = 'Loading'
}
export enum Transaction {
Init = 'Init',
InitFailed = 'InitFailed',
InitSucceeded = 'InitSucceeded',
Filter = 'Filter',
FilterFailed = 'FilterFailed',
FilterSucceeded = 'FilterSucceeded'
}
function createStateMachine (init, filter, setInitError) {
const stateMachine = new StateMachine<State, Transaction>(State.Init)
stateMachine.setTransitions([
{ name: Transaction.Init, from: State.Init, to: State.Initing },
{ name: Transaction.InitFailed, from: State.Initing, to: State.InitingError },
{ name: Transaction.InitSucceeded, from: State.Initing, to: State.Ready },
{ name: Transaction.ReInit, from: State.InitingError, to: State.Initing },
{ name: Transaction.Filter, from: State.Ready , to: State.Loading },
{ name: Transaction.FilterFailed, from: State.Loading, to: State.Ready },
{ name: Transaction.FilterSucceeded, from: State.Loading, to: State.Ready },
])
stateMachine.setActions([
{
action: init,
actionEdge: Transaction.Init,
onErrorEdge: Transaction.InitFailed,
onSuccessEdge: Transaction.InitSucceeded,
onError: (e) => setInitError(e.message)
},
{
action: init,
actionEdge: Transaction.ReInit,
onErrorEdge: Transaction.InitFailed,
onSuccessEdge: Transaction.InitSucceeded,
onError: (e) => errorMessage.value = e.message
},
{
action: filter,
actionEdge: Transaction.Filter,
onErrorEdge: Transaction.FilterFailed,
onSuccessEdge: Transaction.FilterSucceeded,
onError: (e) => alert(e)
}
])
stateMachine.beforeAll(() => setInitError(''))
return stateMachine
}
Як можна побачити на прикладі, ми відділили логіку управління станами компонента від бізнес-логіки. Це зробило такі функції як filter, init значно більш читабельними і зрозумілими, а логіку зміни станів значно більш впорядкованою і контрольованою.
Більше не потрібно думати, який флаг ще потрібно проконтролювати при завантаженні продуктів! І, як результат, ми отримали щось на зразок:

Можна помітити, що в порівнянні з попереднім варіантом цей значно кращий з точки зору організації компонента, втім складніший для розуміння і підтримки. Це вибір, який завжди потрібно робити, ґрунтуючись на складності створюваного компоненту/сторінки/стору.
Але у світі Front-End розробки не буває все так просто. Всі готові рішення і моє в тому числі дають можливість маніпулювати стейт-машиною ззовні через низку причин. Одна з яких — гнучкість, що необхідна вебзастосункам. Скажімо, ваша система підвантажує продукти через сокети (так, приклад неординарний, але все ж). Тобто ви посилаєте запит на продукти до API, а вони вам потім надходять через сокети в міру завантаження. Це призводить до того, що ви не можете зсередини стейт-машини контролювати цей процес. І це нормально. Так, подібного краще уникати, втім це менше зло, яке служить для куди більшого добра 🙂
Виникає питання, якщо можна робити переходи ззовні, то що робити, коли з якихось причин перехід відбудеться неправильно? Тобто хтось зі стану Ready вирішить перейти до Initing. Цим питанням довгий час задавався і я сам.
З одного боку, в самописній стейт-машині (і такий функціонал є в багатьох) можливо передбачити стан, в який машина буде переходити при виникненні помилки. Як на мене, гарне рішення, втім це змушуватиме користувача перезавантажувати сторінку або ще якось втрачати свій прогрес.
З іншого боку, можна просто кидати сповіщення про те, що щось пішло не так. Аргументацією на користь такого рішення є те, що такі помилкові переходи часто виникають через описану вище асинхронність. І, так, користувач, можливо, втратить якусь інформацію, але загальний прогрес залишиться під контролем. Тоді вибір перезавантажувати сторінку чи ні буде робити сам користувач, а не ми за нього.
Втім, ніхто вам не заважає більш детально продумати ці сценарії і реалізувати рішення, що будуть по-різному себе поводити, залежно від контексту. Для прикладу, ось функція, яка є в моїй самописній стейт-машині (гадаю, її було б краще перейменувати) і могла б бути реалізована в коді:
stateMachine.onGeneralError((e)=>{
if(e.type === StateMachineErrorType.TransactionError) { alert('Something went wrong') }
else { stateMachine.goToErrorState() }
})
В цьому випадку ми переписали стандартну поведінку обробки помилок і зробили її більш адаптивною до контексту, в якому вона відбувається.
І останнє, але дуже важливе: при використанні стейт-машини потрібно уникати деяких речей:
- Не намагайтесь перейти між транзакціями зсередини машини самостійно. Є рішення, які це дозволяють, але це може призводити до найбільш неочікуваних результатів. Таким чином зменшується один з найбільших бенефітів стейт-машини.
- Не намагайтесь в одну стейт-машину засунути весь застосунок. Сказитесь 🙂 Тут точнісінько як з принципом розділення інтерфейсів: краще багато маленьких, ніж одна велика.
Епілог
Вебзастосунки в наші дні рідко можна назвати стабільними. Все частіше розробники надають перевагу швидкості реалізації, а не стабільності роботи. І це не критика з мого боку, це опис стану ринку і потреб бізнесу. Стейт-машина це досить дешевий спосіб зробити застосунок значно більш передбачуваним і продуманим з точки зору UX.
Як було показано вище, її можна використовувати в різних форматах залежно від потреб. Я дуже сподіваюсь що дана стаття хоча б трішки, але поширить цю практику у світі Front-End розробки. Якщо у вас є додаткові питання по темі загалом, або по конкретному кейсу — прошу в коментарі, або особисті повідомлення тут чи на LinkedIn.
Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.

85 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів