В этой статье мы разберемся, почему использование status enum — или конечного автомата — поможет вашему приложению избежать ошибок, с которыми вы можете столкнуться, используя логические значения.
Это адаптированный перевод статьи Stop using isLoading boolean Кента Додса, JS-разработчика и преподавателя программирования. Повествование ведётся от лица автора оригинала.
isLoading
(и подобные ему выражения: isRejected
, isIdle
, isResolved
и другие) создают больше проблем, чем решают. Продемонстрирую это на примере эксперимента с API геолокации. Весь код ниже написан на React, но его можно адаптировать к любому фреймворку или языку.
function geoPositionReducer(state, action) {
switch (action.type) {
case 'error': {
return {
...state,
isLoading: false,
error: action.error,
}
}
case 'success': {
return {
...state,
isLoading: false,
position: action.position,
}
}
default: {
throw new Error(`Unhandled action type: ${action.type}`)
}
}
}
function useGeoPosition() {
const [state, dispatch] = React.useReducer(geoPositionReducer, {
isLoading: true,
position: null,
error: null,
})
React.useEffect(() => {
if (!navigator.geolocation) {
dispatch({
type: 'error',
error: new Error('Geolocation is not supported'),
})
return
}
const geoWatch = navigator.geolocation.watchPosition(
position => dispatch({type: 'success', position}),
error => dispatch({type: 'error', error}),
)
return () => navigator.geolocation.clearWatch(geoWatch)
}, [])
return state
}
Это классический пример использования логических значений в API геолокации — его используют многие разработчики приложений, которые пишут на JS и других языках. У этого кода есть проблема:
function YourPosition() {
const {isLoading, position, error} = useGeoPosition()
if (isLoading) {
return <div>Loading your position...</div>
}
if (position) {
return (
<div>
Lat: {position.coords.latitude}, Long: {position.coords.longitude}
</div>
)
}
if (error) {
return (
<div>
<div>Oh no, there was a problem getting your position:</div>
<pre>{error.message}</pre>
</div>
)
}
}
Если вы видите проблему — отлично. Если нет, разберем еще один пример. Представьте, что пользователь садится в машину и едет по городу. При этом его геолокация меняется, но приложение не успевает ее отследить — например, из-за отсутствия интернета, или невозможности установки текущего положения. Если код построен по тому же принципу, что фрагмент выше, пользователь не увидит ошибки, а приложение будет показывать ему неактуальную геолокацию.
Если показывать геопозицию пользователя только в случае, когда она определена, возникнет противоположная проблема — пользователь видит только сообщения об ошибке, даже если следующие запросы по определению геолокации выполняются успешно.
Есть несколько решений этой проблемы:
error
, если данные о геопозиция получены успешно, или очистить поле position
, когда произошла ошибка.Большинство приложений разрабатывается таким образом, чтобы пользователь не смог сделать что-то неправильно, даже если ему захочется. Или, по крайней мере, так, чтобы ему было удобнее поступать правильно. Поэтому первый вариант решение проблемы можно сразу исключить.
Второй вариант решения тоже не идеален: устройства некоторых пользователей могут передавать последние данные о геолокации, даже если произошла ошибка.
Остается третий вариант. Попробуем реализовать его:
function geoPositionReducer(state, action) {
switch (action.type) {
case 'error': {
return {
...state,
status: 'rejected',
error: action.error,
}
}
case 'success': {
return {
...state,
status: 'resolved',
position: action.position,
}
}
case 'started': {
return {
...state,
status: 'pending',
}
}
default: {
throw new Error(`Unhandled action type: ${action.type}`)
}
}
}
function useGeoPosition() {
const [state, dispatch] = React.useReducer(geoPositionReducer, {
status: 'idle',
position: null,
error: null,
})
React.useEffect(() => {
if (!navigator.geolocation) {
dispatch({
type: 'error',
error: new Error('Geolocation is not supported'),
})
return
}
dispatch({type: 'started'})
const geoWatch = navigator.geolocation.watchPosition(
position => dispatch({type: 'success', position}),
error => dispatch({type: 'error', error}),
)
return () => navigator.geolocation.clearWatch(geoWatch)
}, [])
return state
}
В этом фрагменте появилась еще одна dispatch
, которая поможет нам отличить idle
и pending
. В данном случае разницы между ними нет, но, в некоторых других ситуациях, нужно разграничивать эти два конечных состояния. Это важный нюанс, на который стоит обратить внимание.
Теперь вместо логических значений мы используем переменную status
:
function YourPosition() {
const {status, position, error} = useGeoPosition()
if (status === 'idle' || status === 'pending') {
return <div>Loading your position...</div>
}
if (status === 'resolved') {
return (
<div>
Lat: {position.coords.latitude}, Long: {position.coords.longitude}
</div>
)
}
if (status === 'rejected') {
return (
<div>
<div>Oh no, there was a problem getting your position:</div>
<pre>{error.message}</pre>
</div>
)
}
// could also use a switch or nested ternary if that's your jam...
}
Использование переменной status
вместо isLoading
помогает точно узнать, в каком состоянии находится процесс определения геолокации в любой момент времени.
Если вы хотите избавиться от выражений вида variable === 'string' в if
, сделайте следующее:
const {status, position, error} = useGeoPosition()
const isLoading = status === 'idle' || status === 'pending'
const isResolved = status === 'resolved'
const isRejected = status === 'rejected'
Здесь возникает пространство для дискуссий — переменные можно хранить в состоянии редьюсера, а не выводить их значения. Однако такой подход делает код уязвимым для невыполнимых состояний.
Если вы действительно хотите, чтобы вашим пользователям не приходилось использовать variable === 'string'
, убедитесь, что вы придерживаетесь status
в своем состоянии. Это поможет гарантировать, что существует только одно возможное значение конечного состояния. После этого вы можете выводить логические состояния:
function useGeoPosition() {
// ... clipped out for brevity ...
return {
isLoading: status === 'idle' || status === 'pending',
isIdle: status === 'idle',
isPending: status === 'pending',
isResolved: status === 'resolved',
isRejected: status === 'rejected',
...state,
}
}
XState — это удобная библиотека для реализации конечных автоматов в коде. Посмотрим, как она работает на реальном примере:
import {Machine, assign} from 'xstate'
import {useMachine} from '@xstate/react'
const context = {position: null, error: null}
const RESOLVE = {
target: 'resolved',
actions: 'setPosition',
}
const REJECT = {
target: 'rejected',
actions: 'setError',
}
const geoPositionMachine = Machine(
{
id: 'geoposition',
initial: 'idle',
context,
states: {
idle: {
on: {
START: 'pending',
REJECT_NOT_SUPPORTED: 'rejectedNotSupported',
},
},
pending: {
on: {RESOLVE, REJECT},
},
resolved: {
on: {RESOLVE, REJECT},
},
rejected: {
on: {RESOLVE, REJECT},
},
rejectedNotSupported: {},
},
},
{
actions: {
setPosition: assign({
position: (context, event) => event.position,
}),
setError: assign({
error: (context, event) => event.error,
}),
},
},
)
function useGeoPosition() {
const [state, send] = useMachine(geoPositionMachine)
React.useEffect(() => {
if (!navigator.geolocation) {
send('REJECT_NOT_SUPPORTED')
return
}
send('START')
const geoWatch = navigator.geolocation.watchPosition(
position => send({type: 'RESOLVE', position}),
error => send({type: 'REJECT', error}),
)
return () => navigator.geolocation.clearWatch(geoWatch)
}, [send])
return state
}
Если вы не знакомы с конечным автоматом или с библиотекой XState, возможно, код в этом разделе покажется вам сложным. Любая абстракция становится понятнее со временем, если уделять ей достаточно внимания.
Читайте также: Как спроектировать правильный конечный автомат на REST
В примере выше можно отметить несколько моментов. Во-первых, в нем есть специальное состояние для случаев, когда определение геолокации не поддерживается — это состояние называется терминальным. Оно подходит и для нашего примера — если определение геолокации не поддерживается, невозможно перейти в какое-либо другое состояние. Конечный автомат в текущей реализации гарантирует, что такого никогда не произойдет.
Во-вторых, в коде больше нет переменной status
, которую нужно поддерживать — теперь это лишь часть конечного значения состояния конечного автомата. Другими словами, теперь status
встроен в конечный автомат.
Вот как мы будем это использовать:
function YourPosition() {
const state = useGeoPosition()
const status = state.value
const {position, error} = state.context
if (status === 'rejectedNotSupported') {
return <div>This browser does not support Geolocation</div>
}
if (status === 'idle' || status === 'pending') {
return <div>Loading your position...</div>
}
if (status === 'resolved') {
return (
<div>
Lat: {position.coords.latitude}, Long: {position.coords.longitude}
</div>
)
}
if (status === 'rejected') {
return (
<div>
<div>Oh no, there was a problem getting your position:</div>
<pre>{error.message}</pre>
</div>
)
}
}
Если вам понравился API выше, вы можете сохранить себе и этот:
function useGeoPosition() {
const [state, send] = useMachine(geoPositionMachine)
// ... clipped out for brevity ...
return {status: state.value, ...state.context}
}
Если вам сложно работать с конечным автоматом, то можно найти пример редьюсера, который будет проще и понятнее. Но если вы знакомы с этой абстракцией, изучите реализацию Дэвида Хуршида, автора XState. Он полностью отказывается от useEffect
— вместо этого он интегрирует логику подписки в конечный автомат. Это означает, что конечный автомат не зависит от того, какой фреймворк используется в пользовательском интерфейсе.
Этот текст — не столько о пользе status enum
и конечных автоматов, сколько о вреде логических значений. Потому что они не могут представлять все фактические состояния, в которых может находиться ваш код в любой момент времени.
Никогда не останавливайтесь: В программировании говорят, что нужно постоянно учиться даже для того, чтобы просто находиться на месте. Развивайтесь с нами — на Хекслете есть сотни курсов по разработке на разных языках и технологиях