Обработка ввода при помощи состояния

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

You will learn

  • Чем декларативное программирование UI отличается от императивного
  • Как перечислить различные визуальные состояния, которые ваш компонент может принимать
  • Как переключаться между визуальными состояниями из кода

Чем декларативное программирование UI отличается от императивного

Когда вы проектируете интерфейс приложения, скорее всего, вы думаете о том, как UI поменяется в ответ на действия пользователя. Давайте рассмотрим форму, через которую пользователь вводит ответ на вопрос:

  • Если вы вводите что-либо в форму, кнопка “Отправить” становится доступной.
  • Когда вы нажимаете “Отправить”, форма и кнопка становятся недоступны, и появляется спиннер.
  • Если запрос на сервер прошёл успешно, форма скрывается, и появляется сообщение “Верно!“.
  • Если запрос завершился с ошибкой, появляется сообщение с ошибкой и форма становится доступной снова.

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

Машину ведёт неуверенный человек, который играет роль JavaScript, а водитель даёт указания как проехать по маршруту.

Illustrated by Rachel Lee Nabors

Человек, который управляет автомобилем, не знает куда вы хотите проехать и только выполняет ваши команды. (И если вы ошибётесь с одним из поворотов, вы окажетесь не в том месте!) Этот подход называют императивным, потому что вы даёте “команды” каждому элементу, от спиннера до кнопки, рассказывая компьютеру как обновить UI.

В следующем примере императивного программирования форма сделана без React. Она использует объектную модель документа (DOM):

async function handleFormSubmit(e) {
  e.preventDefault();
  disable(textarea);
  disable(button);
  show(loadingMessage);
  hide(errorMessage);
  try {
    await submitForm(textarea.value);
    show(successMessage);
    hide(form);
  } catch (err) {
    show(errorMessage);
    errorMessage.textContent = err.message;
  } finally {
    hide(loadingMessage);
    enable(textarea);
    enable(button);
  }
}

function handleTextareaChange() {
  if (textarea.value.length === 0) {
    disable(button);
  } else {
    enable(button);
  }
}

function hide(el) {
  el.style.display = 'none';
}

function show(el) {
  el.style.display = '';
}

function enable(el) {
  el.disabled = false;
}

function disable(el) {
  el.disabled = true;
}

function submitForm(answer) {
  // Добавим задержку, как-будто это запрос на сервер.
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (answer.toLowerCase() == 'стамбул') {
        resolve();
      } else {
        reject(new Error('Хорошая попытка, но это неверный ответ. Попробуйте ещё раз!'));
      }
    }, 1500);
  });
}

let form = document.getElementById('form');
let textarea = document.getElementById('textarea');
let button = document.getElementById('button');
let loadingMessage = document.getElementById('loading');
let errorMessage = document.getElementById('error');
let successMessage = document.getElementById('success');
form.onsubmit = handleFormSubmit;
textarea.oninput = handleTextareaChange;

Управление интерфейсом императивно работает достаточно хорошо для изолированных примеров, но становится экспоненциально более тяжёлым в более сложных системах. Представьте обновление страницы, состоящей из нескольких форм, подобных этой. Чтобы добавить новый элемент или способ взаимодействия, потребуется внимательно просмотреть весь код и убедиться, что вы не привнесли баги. (Например, забыли что-то показать или скрыть).

React был создан, чтобы решить эту проблему.

В React вы не меняете UI напрямую, то есть вы не меняете доступность или видимость непосредственно для компонента. Вместо этого вы описываете то, что вы хотите отобразить, и React заботится о том, как обновить UI. Представьте, что вы поймали такси и описываете водителю место, в которое вы хотите приехать, вместо того, чтобы давать указания на каждом повороте. Это работа водителя довезти вас до пункта назначения, и может он(а) даже знает короткие пути неизвестные вам!

React ведёт машину, а пассажир просит привезти его в опредлённое место на карте. React заботится о маршруте.

Illustrated by Rachel Lee Nabors

Мышление в декларативной парадигме

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

  1. Установите визуальные состояния вашего компонента
  2. Определите что вызывает изменения состояния
  3. Представьте состояние в памяти с useState
  4. Уберите любые несущественные переменные состояния
  5. Соедините обработчики событий с установкой состояния

Шаг 1: Установите визуальные состояния вашего компонента

Вы могли слышать термин из информатики, “конечный автомат”, находящийся в одном из “состояний”. Если вы работаете с дизайнером, вы могли видеть макеты для различных “визуальных состояний”. React находится на пересечении дизайна и информатики, и обе эти идеи стали источниками вдохновения.

Сперва, вы должны представить все возможные “состояния” UI, которые пользователь может увидеть:

  • Начало (empty): Форма с недоступной кнопкой “Отправить”.
  • Ввод (typing): Форма с доступной кнопкой “Отправить”.
  • Отправка (submitting): Форма полностью недоступна. Спиннер на экране.
  • Успех (success): Сообщение с текстом “Верно!” показано вместо формы.
  • Ошибка (error): Аналогично состоянию “Ввод”, но дополнительно показывает сообщение ошибки.

Так же как и дизайнер, вы захотите сделать “макеты” для различных состояний прежде чем добавить логику. Например, вот макет для визуальной части формы. Этот макет управляется пропом status со значением по умолчанию 'empty':

export default function Form({
  status = 'empty'
}) {
  if (status === 'success') {
    return <h1>Верно!</h1>
  }
  return (
    <>
      <h2>Викторина по городам</h2>
      <p>
        В каком городе рекламный щит превращает воздух в питьевую воду?
      </p>
      <form>
        <textarea />
        <br />
        <button>
          Отправить
        </button>
      </form>
    </>
  )
}

Вы можете назвать этот проп как вам нравится, наименование не важно. Попробуйте изменить status = 'empty' на status = 'success', и вы увидите сообщение о правильном ответе. Создание визуальных макетов позволяет быстро определиться с интерфейсом, прежде чем вы привяжете логику. Далее более конкретный прототип того же компонента, по-прежнему под “управлением” пропа status:

export default function Form({
  // Попробуйте 'submitting', 'error', 'success':
  status = 'empty'
}) {
  if (status === 'success') {
    return <h1>Верно!</h1>
  }
  return (
    <>
      <h2>Викторина по городам</h2>
      <p>
        В каком городе рекламный щит превращает воздух в питьевую воду?
      </p>
      <form>
        <textarea disabled={
          status === 'submitting'
        } />
        <br />
        <button disabled={
          status === 'empty' ||
          status === 'submitting'
        }>
          Отправить
        </button>
        {status === 'error' &&
          <p className="Error">
            Хорошая попытка, но это неверный ответ. Попробуйте ещё раз!
          </p>
        }
      </form>
      </>
  );
}

Deep Dive

Отображение нескольких визуальных состояний сразу

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

import Form from './Form.js';

let statusesDict = {
  'empty': 'Начало',
  'typing': 'Ввод',
  'submitting': 'Отправка',
  'success': 'Успех',
  'error': 'Ошибка'
};

let statuses = Object.keys(statusesDict);

export default function App() {
  return (
    <>
      {statuses.map(status => (
        <section key={status}>
          <h4>Форма - {statusesDict[status]}:</h4>
          <Form status={status} />
        </section>
      ))}
    </>
  );
}

Подобные страницы часто называют гидами по стилям (living styleguides) или сборниками историй (storybooks).

Шаг 2: Определите что вызывает изменения состояния

Вы можете обновить состояние в ответ на два вида событий:

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

Illustrated by Rachel Lee Nabors

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

  • Изменение текста в поле ввода (пользователь) должно переключить форму из состояния Начало (Empty) в состояние Ввода (Typing) или обратно, в зависимости от того, пустое поле или нет.
  • Клик на кнопку “Отправить” (пользователь) должен переключить форму в состояние Отправка (Submitting).
  • Успешный сетевой запрос (программа) должен изменить состояние на Успех (Success).
  • Неудачный сетевой запрос (программа) должен изменить состояние на Ошибка (Error) с соответствующим сообщением.

Note

Обратите внимание, что пользовательский ввод, как правило, требует обработку событий!

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

Блок-схема, состоящая из 5 узлов слева направо. Первый узел подписан как 'начало' и соединён стрелкой с узлом 'ввод', стрелка подписана как 'начало ввода'. Узел 'ввод' соединён стрелкой с узлом 'отправка', стрелка подписана как 'нажать кнопку 'Отправить''. Узел 'отправка' слева подписан как 'сетевая ошибка' и соединён с узлом 'ошибка', а справа подписан как 'успешный запрос' и соединён с узлом 'успех'.
Блок-схема, состоящая из 5 узлов слева направо. Первый узел подписан как 'начало' и соединён стрелкой с узлом 'ввод', стрелка подписана как 'начало ввода'. Узел 'ввод' соединён стрелкой с узлом 'отправка', стрелка подписана как 'нажать кнопку 'Отправить''. Узел 'отправка' слева подписан как 'сетевая ошибка' и соединён с узлом 'ошибка', а справа подписан как 'успешный запрос' и соединён с узлом 'успех'.

Состояния формы

Шаг 3: Представьте состояние в памяти с useState

Теперь вам нужно представить визуальные состояния вашего компонента в памяти при помощи useState. Простота — ключ к успеху: каждый кусочек состояния - “подвижный кусочек”, и вы хотите иметь как можно меньше таких “подвижных кусочков”. Более сложные решения ведут к появлению багов!

Начните с самых необходимых переменных состояния. Например, вам необходимо хранить ответ для поля ввода, и ошибку (если она возникла), чтобы сохранить последнюю ошибку:

const [answer, setAnswer] = useState('');
const [error, setError] = useState(null);

Далее, вам нужна переменная состояния, чтобы знать какое из визуальных состояний показать. Обычно существует несколько путей как представить это в памяти, и вы можете поэкспериментировать и выбрать лучший.

Если вы затрудняетесь определить лучший способ, начните с добавления состояния, которое точно потребуется чтобы покрыть все ситуации:

const [isEmpty, setIsEmpty] = useState(true);
const [isTyping, setIsTyping] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isError, setIsError] = useState(false);

Возможно ваша первая идея не будет лучшей, но это абсолютно нормально. Рефакторинг состояния — неотъемлемая часть процесса!

Шаг 4: Уберите любые несущественные переменные состояния

Вы хотите избежать дублирования в состоянии и следить только за тем, что необходимо. Небольшая трата времени на рефакторинг структуры вашего состояния сделает ваши компоненты легче для понимания, уменьшит дублирование и поможет избежать недопониманий. Ваша цель предотвратить случаи, когда состояние в памяти не представляет никакого валидного UI, который ваши пользователи хотят увидеть. (Например, вам не нужно показывать ошибку и делать поле ввода недоступным одновременно, так что пользователь не сможет исправить ошибку!)

Ниже приведены вопросы, которые вы можете задать себе о ваших переменных состояния:

  • Порождает ли это состояние парадоксы? Например, isTyping и isSubmitting не могут быть одновременно true. Обычно парадокс говорит о том, что состояние недостаточно ограниченно. Мы имеем четыре возможных комбинаций двух булевых значений, но только три будут валидными состояниями. Чтобы удалить “невозможное” состояние, вы можете объединить текущие в переменную status, которая должна принимать одно из значений: 'typing', 'submitting', или 'success'.
  • Доступна ли та же информация в других переменных состояния? Другой парадокс: isEmpty и isTyping не могут быть true в одно и то же время. Сделав их отдельными переменными, вы рискуете получить рассинхронизацию их значений и баги в приложении. К счастью, вы можете удалить isEmpty и делать проверку answer.length === 0.
  • Можете ли вы получить ту же информацию из инверсии другой переменной состояния? isError не нужна, потому что вы можете проверять error !== null.

После этой ревизии у нас осталось 3 (вместо 7!) необходимых переменных состояния:

const [answer, setAnswer] = useState('');
const [error, setError] = useState(null);
const [status, setStatus] = useState('typing'); // 'typing', 'submitting', or 'success'

Вы знаете, что они необходимы, потому что удаление любой из них приведёт к поломке в функциональности.

Deep Dive

Избавляемся от “невозможных” состояний с редюсером

Эти три переменных являются хорошим представлением состояния данной формы. Однако, некоторые промежуточные состояния не имеют особого смысла. Например, error со значением, отличным от null, не имеет смысла, когда status имеет значение 'success'. Чтобы более точно смоделировать состояние, вы можете поместить его в редюсер. Редюсеры позволяют объединить множество переменных состояния в один объект и консолидировать всю связанную логику!

Шаг 5: Соедините обработчики событий с установкой состояния

Наконец, создайте обработчики событий, которые будут обновлять состояние. Ниже финальный код формы, со всеми обработчиками событий, привязанными к соответствующим частям состояния:

import { useState } from 'react';

export default function Form() {
  const [answer, setAnswer] = useState('');
  const [error, setError] = useState(null);
  const [status, setStatus] = useState('typing');

  if (status === 'success') {
    return <h1>Верно!</h1>
  }

  async function handleSubmit(e) {
    e.preventDefault();
    setStatus('submitting');
    try {
      await submitForm(answer);
      setStatus('success');
    } catch (err) {
      setStatus('typing');
      setError(err);
    }
  }

  function handleTextareaChange(e) {
    setAnswer(e.target.value);
  }

  return (
    <>
      <h2>Викторина по городам</h2>
      <p>
        В каком городе рекламный щит превращает воздух в питьевую воду?
      </p>
      <form onSubmit={handleSubmit}>
        <textarea
          value={answer}
          onChange={handleTextareaChange}
          disabled={status === 'submitting'}
        />
        <br />
        <button disabled={
          answer.length === 0 ||
          status === 'submitting'
        }>
          Отправить
        </button>
        {error !== null &&
          <p className="Error">
            {error.message}
          </p>
        }
      </form>
    </>
  );
}

function submitForm(answer) {
  // Добавим задержку, как-будто это запрос на сервер.
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      let shouldError = answer.toLowerCase() !== 'лима'
      if (shouldError) {
        reject(new Error('Хорошая попытка, но это неверный ответ. Попробуйте ещё раз!'));
      } else {
        resolve();
      }
    }, 1500);
  });
}

Хотя этот код объемнее чем первый императивный пример, он менее хрупок. Выражая все взаимодействия в виде изменений состояния, вы сможете добавлять новые визуальные состояния без конфликтов с существующими. Этот подход также позволит менять внешний вид приложения без изменений логики взаимодействий.

Recap

  • Декларативное программирование означает описание UI для каждого визуального состояния без управления отдельными элементами на странице (императивное).
  • При разработке компонента:
    1. Установите все возможные визуальные состояния.
    2. Определите все пользовательские и программные события, которые могут изменить состояние.
    3. Смоделируйте состояние с useState.
    4. Уберите несущественные состояния, чтобы избежать багов и парадоксов.
    5. Соедините обработчики событий с установкой состояния.

Challenge 1 of 3:
Добавление и удаление CSS-класса

Сделайте так, чтобы клик по картинке удалил CSS-класс с внешнего <div> и добавил класс picture--active на <img>. Клик по фону должен восстановить исходные CSS-классы.

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

export default function Picture() {
  return (
    <div className="background background--active">
      <img
        className="picture"
        alt="Разноцветная деревня Кампунг Пеланги в Индонезии"
        src="https://i.imgur.com/5qwVYb1.jpeg"
      />
    </div>
  );
}