12k
All articles

Создаём приложение-викторину на конечном автомате

Создайте quiz-приложение с XState v5 в React. Опишите четыре экрана как state machine, замените boolean flags и защитите финальное состояние.

OpenReplay Team
OpenReplay Team
Создаём приложение-викторину на конечном автомате

Конечный автомат заменяет груду булевых флагов одним именованным состоянием, благодаря чему React-компонент может находиться только в одном из тех экранов, которые вы намеренно определили.

Это подкрадывается незаметно. Экран начинается с одного isLoading, обзаводится hasAnswered, затем isFinished, и в какой-то момент уже никто не может сказать, какие комбинации допустимы. В этой статье мы смоделируем одну небольшую функциональность — викторину — в виде автомата XState v5 с четырьмя состояниями и покажем, что это даёт по сравнению с версией на флагах. Для более широкого знакомства с библиотекой хорошей отправной точкой будет обзор XState от OpenReplay, хотя его код написан ещё до выхода v5; здесь же мы соберём один файл от начала до конца. Код рассчитан на XState v5 и релиз v6 пакета @xstate/react.

Ключевые выводы

  • Три булевых флага описывают восемь комбинаций, но у викторины всего четыре допустимых экрана, а значит, остаются четыре состояния, в которые ваш UI может попасть, но которые никогда не были предусмотрены дизайном.
  • В XState состояния описывают, что пользователь может делать прямо сейчас, а context хранит данные, с которыми эти состояния работают: «answered» — это состояние, а список вопросов, текущий индекс и счёт относятся к context.
  • В XState v5 guard — это функция, получающая { context, event } и возвращающая true или false; false означает, что переход, к которому она привязана, пропускается.
  • Добавить шаг просмотра результатов в компонент на флагах — значит завести четвёртый флаг и шестнадцать комбинаций; добавить его в автомат — значит добавить одно новое состояние и его переходы.

Викторина и её четыре состояния

У викторины ровно четыре экрана, и каждый соответствует одному именованному состоянию. idle: стартовый экран, ничего не загружено, одна кнопка. question: на экране вопрос, варианты ответа кликабельны. answered: пользователь выбрал вариант, видна обратная связь, появляется кнопка Next. results: итоговый счёт с предложением начать заново. Любой рендер, который когда-либо произведёт компонент, принадлежит одному из этих четырёх состояний.

Почему булевы флаги разваливаются?

Три булевых флага — isLoading, hasAnswered и isFinished — описывают восемь возможных комбинаций, но у викторины всего четыре допустимых экрана. Остаются четыре состояния, в которые ваш UI физически может попасть, но которые дизайн никогда не учитывал.

isLoadinghasAnsweredisFinishedЭкран
falsefalsefalsequestion
truefalsefalseзагрузка следующего вопроса
falsetruefalseanswered
falsefalsetrueresults
truetruefalseнет (ответ во время загрузки)
truefalsetrueнет
falsetruetrueнет
truetruetrueнет

Первая недопустимая строка — не гипотеза. Вот как это происходит:

function goToNext() {
  setIsLoading(true);
  fetchQuestion(index + 1).then((q) => {
    setQuestion(q);
    setHasAnswered(false); // reset arrives only when the fetch resolves
    setIsLoading(false);
  });
}

Пользователь отвечает, нажимает Next, и до тех пор, пока запрос не завершится, компонент одновременно держит isLoading: true и hasAnswered: true: спиннер рядом с подсвеченным ответом на предыдущий вопрос. Флаги ничем не связаны друг с другом, поэтому ничто этому не препятствует. В записях сессий викторин и пошаговых мастеров у этого класса багов есть узнаваемая сигнатура: экран, которого не должно существовать, например подсвеченный ответ без отрендеренного вопроса — именно так неучтённая комбинация флагов выглядит со стороны пользователя.

Моделирование викторины в виде конечного автомата в React

Автомат именует четыре состояния и события, которые переводят между ними, — и больше ничего, поэтому каждый переход задан явно, а всё неперечисленное невозможно. createMachine принимает всё определение одним объектом:

import { createMachine, assign } from 'xstate';

const questions = [
  { text: '2 + 2?', options: ['3', '4'], answer: 1 },
  { text: 'Capital of France?', options: ['Paris', 'Lyon'], answer: 0 },
  { text: 'Largest planet?', options: ['Earth', 'Jupiter'], answer: 1 },
];

export const quizMachine = createMachine({
  id: 'quiz',
  initial: 'idle',
  context: { questions, currentIndex: 0, score: 0 },
  states: {
    idle: {
      on: { START: { target: 'question' } },
    },
    question: {
      on: {
        ANSWER: {
          target: 'answered',
          actions: assign({
            score: ({ context, event }) =>
              event.optionIndex === context.questions[context.currentIndex].answer
                ? context.score + 1
                : context.score,
          }),
        },
      },
    },
    answered: {
      on: {
        NEXT: {
          target: 'question',
          actions: assign({
            currentIndex: ({ context }) => context.currentIndex + 1,
          }),
        },
      },
    },
    results: {
      on: {
        RESTART: {
          target: 'idle',
          actions: assign({ currentIndex: 0, score: 0 }),
        },
      },
    },
  },
});

Событие ANSWER в состоянии results не делает ничего — по самой конструкции, а не благодаря защитным if-проверкам. Остаётся один пробел: пока что ничто не приводит к results. Раздел про guard его закрывает.

Context против состояния: данные — это не состояние

Состояния описывают, что пользователь может делать прямо сейчас; context хранит данные, с которыми эти состояния работают. Для этой викторины «answered» — состояние, потому что оно меняет то, какие элементы управления работают, а список вопросов, текущий индекс и счёт — это context, потому что они меняют то, что отображается в рамках экрана, а не то, каким этот экран является. Критерий универсален: если значение меняет то, что пользователь может делать, моделируйте его как состояние; если оно меняет то, что он видит внутри того же экрана, поместите его в context и обновляйте через assign, колбэк которого получает { context, event }, как показано выше. Захардкоженные вопросы позволяют не отвлекаться от темы статьи; чтобы загружать их по сети, вызовите актора fromPromise в состоянии загрузки и выполните assign из event.output.

Подключение автомата к React-компоненту

Хук useMachine из @xstate/react возвращает массив из трёх элементов: текущий снапшот, функцию send и ссылку на запущенного актора. Компонент ниже берёт первые два, рендерит на основе snapshot.value и сообщает о действиях пользователя как о событиях, вместо того чтобы выставлять флаги.

import { useMachine } from '@xstate/react';
import { quizMachine } from './quizMachine';

export default function Quiz() {
  const [snapshot, send] = useMachine(quizMachine);
  const { questions, currentIndex, score } = snapshot.context;
  const question = questions[currentIndex];

  if (snapshot.value === 'idle')
    return <button onClick={() => send({ type: 'START' })}>Start quiz</button>;

  if (snapshot.value === 'results')
    return (
      <div>
        <p>Score: {score} / {questions.length}</p>
        <button onClick={() => send({ type: 'RESTART' })}>Play again</button>
      </div>
    );

  return (
    <div>
      <p>{question.text}</p>
      {question.options.map((option, i) => (
        <button
          key={option}
          disabled={snapshot.value === 'answered'}
          onClick={() => send({ type: 'ANSWER', optionIndex: i })}
        >
          {option}
        </button>
      ))}
      {snapshot.value === 'answered' && (
        <button onClick={() => send({ type: 'NEXT' })}>Next</button>
      )}
    </div>
  );
}

Никакого учёта флагов в обработчиках нет. Компонент констатирует факты («пользователь ответил»), а автомат решает, что они означают.

Как ограничить условие завершения с помощью guard?

Guard — это небольшая проверка без побочных эффектов. XState передаёт в неё context автомата и только что поступившее событие, а она возвращает true или false. False означает, что переход, к которому она привязана, пропускается. Чтобы направить последний ответ в results, замените переход NEXT массивом переходов с guard. XState проходит по этому массиву сверху вниз, использует первую запись, guard которой вернул true, и доходит до записи без guard в конце только тогда, когда ни один из них не сработал:

    answered: {
      on: {
        NEXT: [
          {
            guard: ({ context }) =>
              context.currentIndex >= context.questions.length - 1,
            target: 'results',
          },
          {
            target: 'question',
            actions: assign({
              currentIndex: ({ context }) => context.currentIndex + 1,
            }),
          },
        ],
      },
    },

Условие завершения теперь живёт в одном месте, вместо того чтобы выводиться заново в каждом обработчике, который трогает индекс.

Что даёт вам автомат?

Недопустимые строки в таблице выше теперь недостижимы, а не просто маловероятны: никакая последовательность событий не переведёт автомат в состояние «ответ во время загрузки», потому что туда не ведёт ни один переход. Второй выигрыш — изменения. Добавить шаг просмотра результатов в версию на булевых флагах — значит завести четвёртый флаг и держать в голове шестнадцать комбинаций; добавить его в автомат — значит добавить одно новое состояние review, переход в него из results и один переход обратно. Компонент получает одну ветку if, а все существующие состояния продолжают вести себя ровно так же, как прежде.

Где применять этот паттерн дальше

Паттерн масштабируется вниз сильнее, чем предполагает большинство руководств: любой экран, где вы ловите себя на написании if (isX && !isY), — кандидат на четыре-пять именованных состояний. Возьмите файл автомата из этой статьи, подставьте состояния и события своей предметной области и сделайте так, чтобы баги из той таблицы комбинаций стали чем-то, что ваш компонент попросту не способен выразить.

Часто задаваемые вопросы

В чём разница между автоматом XState и useReducer?

Оба централизуют переходы в одной функции, но редьюсер принимает любое действие в любом состоянии, поэтому недопустимые комбинации остаются выразимыми. Конечный автомат реагирует только на события, перечисленные в его текущем состоянии: событие ANSWER, полученное в состоянии results, игнорируется по самой конструкции. Кроме того, редьюсер оставляет множество возможных состояний неявным, скрытым в данных, тогда как автомат именует каждое состояние явно.

Требует ли XState v5 использования TypeScript?

Нет. XState v5 работает на чистом JavaScript, и каждый фрагмент кода в этой статье работает без типов. Если же вы используете TypeScript, минимальная поддерживаемая версия — 5.0. С TypeScript API setup() позволяет объявить типы для context и событий, чтобы переходы и вызовы assign проверялись на этапе компиляции.

Будет ли код из руководств по XState v4 работать на v5?

Нет, при миграции с v4 на v5 несколько ключевых API были переименованы. Machine() стал createMachine(), interpret() стал createActor(), свойство cond у переходов стало guard, а вызываемые сервисы (services) стали акторами. Сигнатуры колбэков тоже изменились: действия и guard'ы теперь получают один объект, содержащий context и event, вместо отдельных аргументов. Если во фрагменте кода используется cond или interpret, это код v4, и на v5 он не заработает.

Могут ли два React-компонента разделять одно и то же состояние автомата XState?

Не путём двукратного вызова useMachine: каждый вызов создаёт независимого актора со своим снапшотом, поэтому два компонента, использующие useMachine(quizMachine), держат раздельное, несинхронизированное состояние. Чтобы разделять один запущенный автомат, создайте актора один раз и распространите его — либо с помощью createActorContext из пакета xstate react, либо передавая actorRef, возвращаемый useMachine, вниз через props и считывая его через useSelector.

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.