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

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

Виведення кількох візуальних станів водночас

If a component has a lot of visual states, it can be convenient to show them all on one page:

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

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

Крок 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>
  );
}