Реагування станом на введення

React надає декларативний спосіб маніпулювання UI. Замість маніпулювання окремими шматочками UI безпосередньо, слід описувати різні стани, в яких може перебувати компонент, і перемикатися між ними у відповідь на введення користувачем. Це схоже на те, як UI уявляють дизайнери.

You will learn

  • Як декларативне програмування UI відрізняється від імперативного
  • Як перелічити різні візуальні стани, в яких може перебувати компонент
  • Як у коді запустити зміни між різними візуальними станами

Як декларативний UI відрізняється від імперативного

Під час розробки взаємодій із UI, ймовірно, ви думаєте про те, як UI змінюється у відповідь на дії користувача. Уявіть форму, що дає користувачу змогу надіслати відповідь:

  • Коли ви друкуєте щось у формі, кнопка “Надіслати” стає увімкненою.
  • Коли ви натискаєте “Надіслати”, то і форма, і кнопка стають вимкненими, а натомість з’являється елемент індикації надсилання.
  • Якщо мережевий запит успішний, то форма ховається, і з’являється повідомлення “Дякуємо”.
  • Якщо мережевий запит невдалий, то з’являється повідомлення про помилку, а форма знову стає ввімкненою.

В імперативному програмуванні описане вище безпосередньо відповідає тому, як реалізується взаємодія. Доводиться писати прямі інструкції для маніпулювання UI, залежно від того, що відбувається. Ось іще один спосіб подумати про це: уявіть, що їдете з кимось в авто й керуєте поїздкою, називаючи кожний поворот.

В авто, що керується стривоженою особою, яка уособлює JavaScript, пасажир пропонує водієві виконати послідовність складних навігацій, поворот за поворотом.

Illustrated by Rachel Lee Nabors

Водій не знає, куди ви хочете потрапити, він просто виконує команди. (І якщо ви переплутаєте орієнтири, то опинитеся не там!) Це зветься імперативним, тому що доводиться “командувати” кожним елементом, від індикатора до кнопки, кажучи комп’ютеру, як саме оновлювати UI.

У цьому прикладі імперативного програмування 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;

Імперативна маніпуляція UI добре працює в ізольованих прикладах, але її складність зростає експоненційно в складніших системах. Уявіть оновлення сторінки, сповненої різних форм, схожих на цю. Додавання нового елемента UI або нової взаємодії може вимагати ретельної перевірки всього наявного коду, аби пересвідчитись, що не з’явився якийсь дефект (наприклад, не забули показати чи приховати щось).

React створений для розв’язання цієї проблеми.

У React ви не маніпулюєте UI безпосередньо, тобто ви не вмикаєте, вимикаєте, показуєте чи приховуєте компоненти безпосередньо. Замість цього ви оголошуєте, що хочете показати, і React з’ясовує, як оновити UI. Уявіть, що ви ніби сідаєте в таксі й кажете водієві, куди хочете поїхати, але не керуєте кожним його поворотом. Це його робота — довезти вас туди, і він може навіть знати короткі шляхи, про які ви б не подумали!

В авто, яким кермує React, пасажир просить довезти його до конкретного місця на мапі. React з'ясовує, як це зробити.

Illustrated by Rachel Lee Nabors

Декларативне осмислення UI

Вище ви побачили, як реалізувати форму імперативно. Щоб краще зрозуміти, як мислити у стилі React, далі ми проведемо вас крізь повторну реалізацію того самого UI за допомогою React:

  1. З’ясуйте різні візуальні стани свого компонента
  2. Визначте, що запускає ці зміни стану
  3. Представте стан у пам’яті за допомогою useState
  4. Вилучіть усі несуттєві змінні стану
  5. Приєднайте обробники подій, щоб задати стан

Крок 1. З’ясуйте різні візуальні стани свого компонента

Досліджуючи комп’ютерні науки, ви могли чути про “скінченний автомат”, що перебуває в одному з декількох “станів”. Якщо ви працюєте разом із дизайнером, то могли бачити макети різних “візуальних станів”. React розташований на перетині дизайну та комп’ютерних наук, тож обидві ці ідеї — наші джерела натхнення.

По-перше, необхідно візуалізувати всі різні “стани” UI, які користувач може побачити:

  • Порожній — форма має вимкнену кнопку “Надіслати”.
  • Друкування — форма має ввімкнену кнопку “Надіслати”.
  • Надсилання — форма повністю вимкнена. Показано індикатор надсилання.
  • Успіх — замість форми показано повідомлення “Дякуємо”.
  • Помилка — те саме, що для стану “Друкування”, але з додатковим повідомленням про помилку.

Як і дизайнеру, вам захочеться “окреслити” чи “створити імітації” різних станів, перш ніж додавати логіку. Наприклад, ось імітація суто візуальної частини форми. Ця імітація контролюється пропом 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', щоб побачити появу повідомлення про успіх. Імітування дає змогу швидко ітеруватися в розробці UI до закручування будь-якої логіки. Ось змістовніший прототип того самого компонента, так само “контрольований” пропом 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 statuses = [
  'empty',
  'typing',
  'submitting',
  'success',
  'error',
];

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

Сторінки, схожі на цю, нерідко звуть “живими стилістичними настановами” (“living styleguides”) або “сторибуками” (“storybooks”).

Крок 2. Визначте, що запускає ці зміни стану

Запускати зміни стану можна у відповідь на два види введення:

  • Людське введення, наприклад, клацання кнопки, друкування в полі, перехід за посиланням.
  • Комп’ютерне введення, наприклад, надходження мережевої відповіді, завершення таймера, завантаження зображення.
Палець.
Людське введення
Одиниці та нулі.
Комп'ютерне введення

Illustrated by Rachel Lee Nabors

В обох випадках необхідно задати змінні стану, щоб UI оновився. У формі, що ми розробляємо, необхідно змінити стан у відповідь на кілька різних введень:

  • Зміни в текстовому полі (людське) повинні перемкнути форму зі стану Порожній до стану Друкування та навпаки, залежно від того, чи є текстове поле порожнім.
  • Клацання кнопки “Надіслати” (людське) повинно перемкнути форму до стану Надсилання.
  • Успішна мережева відповідь (комп’ютерне) повинна перемикати її до стану Успіх.
  • Невдала мережева відповідь (комп’ютерне) повинна перемикати її до стану Помилка з відповідним повідомленням про помилку.

Note

Зверніть увагу: людське введення нерідко потребує обробників подій!

Щоб легше візуалізувати ці переходи, спробуйте намалювати кожний стан на папері як підписане коло, а кожну зміну між двома станами — стрілкою. Так ви можете накреслити чимало переходів і знайти дефекти задовго до початку реалізації.

Діаграма зліва направо, що має 5 вузлів. Перший вузол підписаний 'Empty' (порожній) і має стрілку, підписану 'Start typing' (початок друку), сполучену з вузлом, підписаним 'Submitting' (надсилання), з якого виходять дві стрілки. Стрілка ліворуч підписана 'Network error' (мережева помилка) і сполучає з вузлом, підписаним 'Error' (помилка). Стрілка праворуч підписана 'Network success' (мережевий успіх) і сполучає з вузлом, підписаним 'Success' (успіх).
Діаграма зліва направо, що має 5 вузлів. Перший вузол підписаний 'Empty' (порожній) і має стрілку, підписану 'Start typing' (початок друку), сполучену з вузлом, підписаним 'Submitting' (надсилання), з якого виходять дві стрілки. Стрілка ліворуч підписана 'Network error' (мережева помилка) і сполучає з вузлом, підписаним 'Error' (помилка). Стрілка праворуч підписана 'Network success' (мережевий успіх) і сполучає з вузлом, підписаним 'Success' (успіх).

Стани форми

Крок 3. Представте стан у пам’яті за допомогою useState

Далі необхідно представити візуальні стани свого компонента у пам’яті, використовуючи useState. Ключовою в цій справі є простота: кожна дрібка стану — це “рухома деталь”, і вам краще мати якомога менше таких “рухомих деталей”. Більше складності — більше помилок!

Почніть зі стану, який абсолютно точно повинен бути присутній. Наприклад, необхідно зберігати answer — значення поля, а також error, аби зберігати останню помилку:

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' або 'success'

Вони суттєві, тому що жодну з них не можна вилучити, не зламавши функціонал.

Deep Dive

Усунення “неможливих” станів за допомогою редуктора

Ці три змінні є достатньо добрим представленням стану цієї форми. Проте є деякі проміжні стани, що не дуже мають зміст. Наприклад, ненульове значення error не має змісту, коли 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() !== 'lima'
      if (shouldError) {
        reject(new Error('Гарний варіант, але неправильна відповідь. Спробуйте ще!'));
      } else {
        resolve();
      }
    }, 1500);
  });
}

Попри те, що цей код не перевищує за розміром вихідний імперативний приклад, він куди надійніший. Вираження всіх взаємодій у вигляді змін до стану дає змогу пізніше додавати нові візуальні стани, не ламаючи наявних. Також це дає змогу змінювати те, що повинно виводитися в кожному стані, не змінюючи логіки самої взаємодії.

Recap

  • Декларативне програмування означає описувати UI для кожного візуального стану, а не займатися мікроменеджментом UI (імперативним стилем).
  • Для розробки компонента:
    1. З’ясуйте всі його візуальні стани.
    2. Визначте людські та комп’ютерні пускачі змін стану.
    3. Змоделюйте стан за допомогою useState.
    4. Вилучіть несуттєві частини стану, щоб уникнути помилок і парадоксів.
    5. Під’єднайте обробників подій, що задаватимуть значення стану.

Challenge 1 of 3:
Додавання та вилучення класу CSS

Зробіть так, щоб клацання картинки вилучало клас CSS background--active з зовнішнього <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>
  );
}