Главная | Все статьи | Код

Почему не стоит использовать логические значения isLoading в своем коде

Время чтения статьи ~10 минут
Почему не стоит использовать логические значения isLoading в своем коде главное изображение

В этой статье мы разберемся, почему использование status enum — или конечного автомата — поможет вашему приложению избежать ошибок, с которыми вы можете столкнуться, используя логические значения.

Это адаптированный перевод статьи Stop using isLoading boolean Кента Додса, JS-разработчика и преподавателя программирования. Повествование ведётся от лица автора оригинала.

Про isLoading

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>
    )
  }
}

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

Если показывать геопозицию пользователя только в случае, когда она определена, возникнет противоположная проблема — пользователь видит только сообщения об ошибке, даже если следующие запросы по определению геолокации выполняются успешно.

Есть несколько решений этой проблемы:

  1. Убедиться, что приложение всегда показывает местоположение и ошибку.
  2. Очистить поле error, если данные о геопозиция получены успешно, или очистить поле position, когда произошла ошибка.
  3. Вернуть дополнительное свойство, которое определяет текущий статус информации о геопозиции.

Большинство приложений разрабатывается таким образом, чтобы пользователь не смог сделать что-то неправильно, даже если ему захочется. Или, по крайней мере, так, чтобы ему было удобнее поступать правильно. Поэтому первый вариант решение проблемы можно сразу исключить.

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

Остается третий вариант. Попробуем реализовать его:

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

Никогда не останавливайтесь: В программировании говорят, что нужно постоянно учиться даже для того, чтобы просто находиться на месте. Развивайтесь с нами — на Хекслете есть сотни курсов по разработке на разных языках и технологиях

Аватар пользователя Oleg Sabitov
Oleg Sabitov 07 июля 2021
20
Рекомендуемые программы
профессия
от 6 300 ₽ в месяц
Разработка фронтенд-компонентов для веб-приложений
10 месяцев
с нуля
Старт 25 апреля
профессия
от 6 300 ₽ в месяц
Разработка веб-приложений на Django
10 месяцев
с нуля
Старт 25 апреля
профессия
от 6 183 ₽ в месяц
Ручное тестирование веб-приложений
4 месяца
с нуля
Старт 25 апреля
профессия
от 6 300 ₽ в месяц
Разработка приложений на языке Java
10 месяцев
с нуля
Старт 25 апреля
профессия
от 5 025 ₽ в месяц
новый
Сбор, анализ и интерпретация данных
9 месяцев
с нуля
Старт 25 апреля
профессия
от 6 300 ₽ в месяц
Разработка веб-приложений на Laravel
10 месяцев
с нуля
Старт 25 апреля
профессия
от 5 840 ₽ в месяц
Создание веб-приложений со скоростью света
5 месяцев
c опытом
Старт 25 апреля
профессия
от 9 900 ₽ в месяц
Разработка фронтенд- и бэкенд-компонентов для веб-приложений
16 месяцев
с нуля
Старт 25 апреля
профессия
от 6 300 ₽ в месяц
Разработка бэкенд-компонентов для веб-приложений
10 месяцев
с нуля
Старт 25 апреля
профессия
новый
Автоматизированное тестирование веб-приложений на JavaScript
8 месяцев
c опытом
в разработке
Старт 25 апреля
профессия
Верстка с использованием последних стандартов CSS
5 месяцев
с нуля
Старт в любое время