Мислення в React

React може змінити те, як ви думаєте про дизайн, на який дивитеся, та застосунки, які створюєте. Коли ви створюєте користувацький інтерфейс за допомогою React, ви спочатку розбиваєте його на частини, які називаються компонентами. Потім ви описуєте різні візуальні стани для кожного з ваших компонентів. Нарешті, ви з'єднаєте компоненти між собою так, щоб дані проходили через них. У цьому уроці ми проведемо вас через процес створення таблиці даних продукту з можливістю пошуку за допомогою React.

Почніть з макета

Уявіть, що у вас вже є JSON API і макет від дизайнера.

JSON API повертає деякі дані, які виглядають так:

[
  { category: "Fruits", price: "$1", stocked: true, name: "Apple" },
  { category: "Fruits", price: "$1", stocked: true, name: "Dragonfruit" },
  { category: "Fruits", price: "$2", stocked: false, name: "Passionfruit" },
  { category: "Vegetables", price: "$2", stocked: true, name: "Spinach" },
  { category: "Vegetables", price: "$4", stocked: false, name: "Pumpkin" },
  { category: "Vegetables", price: "$1", stocked: true, name: "Peas" }
]

Макет виглядає так:

Щоб реалізувати інтерфейс користувача у React, ви зазвичай виконуєте ті самі п'ять кроків.

Крок 1: Розбиття інтерфейсу на ієрархію компонентів

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

Залежно від вашого досвіду, ви можете думати про розділення дизайну на компоненти різними способами:

  • При програмуваннівикористовуйте ті самі методи для прийняття рішення про створення нової функції або об'єкта. Однією з таких технік є принцип єдиної відповідальності, тобто компонент в ідеалі повинен робити лише одну річ. Якщо він розростається, його слід розбити на менші підкомпоненти.
  • CSS-подумайте, для чого вам потрібні селектори класів. (Втім, компоненти є дещо менш деталізованими)
  • .
  • Дизайн - подумайте, як ви організуєте шари дизайну.

Якщо ваш JSON добре структурований, ви часто бачитиме, що він природно відображається у структурі компонентів вашого інтерфейсу. Це тому, що інтерфейс і моделі даних часто мають однакову інформаційну архітектуру - тобто однакову форму. Розділіть ваш інтерфейс на компоненти, де кожен компонент відповідає одній частині вашої моделі даних.

На цьому екрані є п'ять компонентів:

<img width="500" src="/images/docs/s_thinking-in-react_ui_outline. png" style="margin: 0 auto;" /><ol><li><code>FilterableProductTable (сірий) містить весь застосунок.
  • SearchBar (синій) отримує вхідні дані користувача.
  • ProductTable (лавандовий) відображає та фільтрує список відповідно до введених користувачем даних.
  • ProductCategoryRow (зелений) демонструє заголовок для кожної категорії.
  • ProductRow (жовтий) показує рядок для кожного товару.
  • Якщо ви подивитеся на ProductTable (лаванда), то побачите, що заголовок таблиці (який містить мітки "Назва" та "Ціна") не є її власним компонентом. Це справа ваших вподобань, і ви можете піти будь-яким шляхом. У цьому прикладі він є частиною ProductTable, оскільки з'являється всередині списку ProductTable. Однак, якщо цей заголовок стане складнішим (наприклад, якщо ви додасте сортування), ви можете перемістити його у власний компонент ProductTableHeader.

    Тепер, коли ви визначили компоненти у макеті, розташуйте їх в ієрархію. Компоненти, які з'являються всередині іншого компонента у макеті, повинні з'являтися як дочірні в ієрархії:

    • FilterableProductTable
      • SearchBar
      • ProductTable
        • ProductCategoryRow
        • ProductRow

    Крок 2: Створення статичної версії в React

    Тепер, коли у вас є ієрархія компонентів, настав час реалізувати ваш застосунок. Найпростіший підхід - створити версію, яка рендерить інтерфейс на основі вашої моделі даних, не додаючи ніякої інтерактивності... поки що! Часто простіше спочатку створити статичну версію, а інтерактивність додати пізніше. Створення статичної версії вимагає багато тексту і не вимагає роздумів, але додавання інтерактивності вимагає багато роздумів і не вимагає багато тексту.

    Щоб створити статичну версію вашого застосунку, яка рендерить вашу модель даних, вам потрібно створити компоненти, які повторно використовують інші компоненти і передають дані за допомогою пропсів. Пропси - це спосіб передачі даних від батька до нащадка. (Якщо ви знайомі з концепцією стану, не використовуйте стан взагалі для побудови цієї статичної версії. Стан зарезервовано лише для інтерактивності, тобто даних, які змінюються з часом. Оскільки це статична версія програми, він вам не потрібен).

    Ви можете збирати "зверху вниз", починаючи зі складання компонентів, розташованих вище в ієрархії (наприклад, FilterableProductTable), або "знизу вгору", працюючи з компонентів, розташованих нижче (наприклад, ProductRow). У простих прикладах зазвичай легше йти зверху вниз, а у великих проектах - знизу вгору.

    function ProductCategoryRow({ category }) {
      return (
        <tr>
          <th colSpan="2">
            {category}
          </th>
        </tr>
      );
    }
    
    function ProductRow({ product }) {
      const name = product.stocked ? product.name :
        <span style={{ color: 'red' }}>
          {product.name}
        </span>;
    
      return (
        <tr>
          <td>{name}</td>
          <td>{product.price}</td>
        </tr>
      );
    }
    
    function ProductTable({ products }) {
      const rows = [];
      let lastCategory = null;
    
      products.forEach((product) => {
        if (product.category !== lastCategory) {
          rows.push(
            <ProductCategoryRow
              category={product.category}
              key={product.category} />
          );
        }
        rows.push(
          <ProductRow
            product={product}
            key={product.name} />
        );
        lastCategory = product.category;
      });
    
      return (
        <table>
          <thead>
            <tr>
              <th>Name</th>
              <th>Price</th>
            </tr>
          </thead>
          <tbody>{rows}</tbody>
        </table>
      );
    }
    
    function SearchBar() {
      return (
        <form>
          <input type="text" placeholder="Search..." />
          <label>
            <input type="checkbox" />
            {' '}
            Only show products in stock
          </label>
        </form>
      );
    }
    
    function FilterableProductTable({ products }) {
      return (
        <div>
          <SearchBar />
          <ProductTable products={products} />
        </div>
      );
    }
    
    const PRODUCTS = [
      {category: "Fruits", price: "$1", stocked: true, name: "Apple"},
      {category: "Fruits", price: "$1", stocked: true, name: "Dragonfruit"},
      {category: "Fruits", price: "$2", stocked: false, name: "Passionfruit"},
      {category: "Vegetables", price: "$2", stocked: true, name: "Spinach"},
      {category: "Vegetables", price: "$4", stocked: false, name: "Pumpkin"},
      {category: "Vegetables", price: "$1", stocked: true, name: "Peas"}
    ];
    
    export default function App() {
      return <FilterableProductTable products={PRODUCTS} />;
    }
    body {
      padding: 5px
    }
    label {
      display: block;
      margin-top: 5px;
      margin-bottom: 5px;
    }
    th {
      padding-top: 10px;
    }
    td {
      padding: 2px;
      padding-right: 40px;
    }

    (Якщо цей код виглядає страшним, спочатку пройдіть Швидкий старт!)

    Після створення компонентів ви матимете бібліотеку компонентів багаторазового використання, які рендеритимуть вашу модель даних. Оскільки це статичний застосунок, компоненти повертатимуть лише JSX. Компонент на вершині ієрархії (FilterableProductTable) візьме вашу модель даних як проп. Це називається одностороннім потоком даних, оскільки дані течуть вниз від компонента верхнього рівня до компонентів внизу дерева.

    На цьому етапі вам не слід використовувати жодних значень стану. Це буде зроблено на наступному кроці!

    Крок 3: Знайти мінімальне, але повне представлення стану інтерфейсу користувача

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

    Вважайте стан мінімальним набором даних, що змінюються, які має запам'ятати ваша програма. Найважливішим принципом структурування стану є збереження його DRY (Don't Repeat Yourself). З'ясуйте абсолютне мінімальне представлення стану, яке потрібне вашій програмі, і обчислюйте все інше на вимогу. Наприклад, якщо ви створюєте список покупок, ви можете зберігати елементи як масив у стані. Якщо ви хочете також відобразити кількість елементів у списку, не зберігайте кількість елементів як ще одне значення стану, натомість зчитуйте довжину масиву.

    Тепер подумайте про всі дані у цьому прикладі програми:

    1. Оригінальний список продуктів
    2. Текст пошуку, який ввів користувач
    3. Значення прапорця
    4. .
    5. Відфільтрований список товарів

    Які з них є державними? Визначте ті, які не є:

    • Чи залишається вона незмінною з часом? Якщо так, то це не стан.
    • Це передано з батьківського через пропси? Якщо так, то це не стан.
    • Чи можете ви обчислити його на основі наявного стану або пропсів у вашому компоненті? Якщо так, то це безумовно не є станом!

    Те, що залишилося, ймовірно, є станом.

    Пройдімося ще раз по одному:

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

    Це означає, що лише текст пошуку та значення прапорця є станом! Чудово!

    Пропси проти стану

    У React є два типи даних "моделі": пропси та стан. Вони дуже різняться між собою:

    • Пропси схожі на аргументи, які ви передаєте у функцію. Вони дозволяють батьківському компоненту передавати дані дочірньому компоненту і налаштовувати його вигляд. Наприклад, Form може передати проп color до Button.
    • Стан - це як пам'ять компонента. Він дозволяє компоненту відстежувати деяку інформацію та змінювати її у відповідь на взаємодії. Наприклад, Button може відстежувати стан isHovered.

    Пропси та стан - це різні поняття, але вони працюють разом. Батьківський компонент часто зберігає деяку інформацію у стані (щоб мати змогу її змінювати) і передає її вниз дочірнім компонентам як їхні пропси. Нічого страшного, якщо при першому читанні різниця все ще здається нечіткою. Потрібно трохи потренуватися, щоб вона дійсно стала зрозумілою!

    Крок 4: Визначте, де має жити ваша держава

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

    Для кожного фрагменту стану у вашій програмі:

  • Ідентифікувати кожний компонент, який рендерить щось на основі цього стану.
  • Знайдіть їхній найближчий спільний батьківський компонент - компонент, що стоїть над ними в ієрархії.
  • Вирішити, де живе держава:
    1. Часто можна помістити стан безпосередньо у їхнього спільного батька.
    2. Ви також можете помістити стан у якийсь компонент вище їхнього спільного батька.
    3. Якщо ви не можете знайти компонент, у якому є сенс володіти станом, створіть новий компонент виключно для утримання стану і додайте його десь в ієрархії над спільним батьківським компонентом.
  • На попередньому кроці ви знайшли два елементи стану у цій програмі: текст для пошуку та значення прапорця. У цьому прикладі вони завжди відображаються разом, тому є сенс помістити їх в одне місце.

    Тепер давайте розглянемо нашу стратегію для них:

    1. Визначення компонентів, які використовують стан:
      • ProductTable потрібно відфільтрувати список товарів на основі цього стану (текст пошуку та значення прапорця).
      • SearchBar потрібно відобразити цей стан (текст пошуку та значення прапорця).
    2. Знайти їхнього спільного батька: Перший спільний для обох компонентів компонент - FilterableProductTable.
    3. Вирішіть, де живе держава: Текст фільтру та значення перевірених станів збережемо у FilterableProductTable.

    Отже, значення стану будуть жити у FilterableProductTable.

    Додайте стан до компонента за допомогою хука useState() hook. Хуки - це спеціальні функції, які дозволяють вам "зачепитися" за React. Додайте дві змінні стану у верхній частині FilterableProductTable і вкажіть їх початковий стан:

    function FilterableProductTable({ products }) {
      const [filterText, setFilterText] = useState('');
      const [inStockOnly, setInStockOnly] = useState(false);

    Потім передайте filterText та inStockOnly до ProductTable та SearchBar як пропси:

    <div>
      <SearchBar 
        filterText={filterText} 
        inStockOnly={inStockOnly} />
      <ProductTable 
        products={products}
        filterText={filterText}
        inStockOnly={inStockOnly} />
    </div>

    Ви можете почати спостерігати за поведінкою вашої програми. Відредагуйте початкове значення filterText з useState('') на useState('fruit') у коді пісочниці нижче. Ви побачите як текст пошукового запиту, так і оновлення таблиці:

    import { useState } from 'react';
    
    function FilterableProductTable({ products }) {
      const [filterText, setFilterText] = useState('');
      const [inStockOnly, setInStockOnly] = useState(false);
    
      return (
        <div>
          <SearchBar 
            filterText={filterText} 
            inStockOnly={inStockOnly} />
          <ProductTable 
            products={products}
            filterText={filterText}
            inStockOnly={inStockOnly} />
        </div>
      );
    }
    
    function ProductCategoryRow({ category }) {
      return (
        <tr>
          <th colSpan="2">
            {category}
          </th>
        </tr>
      );
    }
    
    function ProductRow({ product }) {
      const name = product.stocked ? product.name :
        <span style={{ color: 'red' }}>
          {product.name}
        </span>;
    
      return (
        <tr>
          <td>{name}</td>
          <td>{product.price}</td>
        </tr>
      );
    }
    
    function ProductTable({ products, filterText, inStockOnly }) {
      const rows = [];
      let lastCategory = null;
    
      products.forEach((product) => {
        if (
          product.name.toLowerCase().indexOf(
            filterText.toLowerCase()
          ) === -1
        ) {
          return;
        }
        if (inStockOnly && !product.stocked) {
          return;
        }
        if (product.category !== lastCategory) {
          rows.push(
            <ProductCategoryRow
              category={product.category}
              key={product.category} />
          );
        }
        rows.push(
          <ProductRow
            product={product}
            key={product.name} />
        );
        lastCategory = product.category;
      });
    
      return (
        <table>
          <thead>
            <tr>
              <th>Name</th>
              <th>Price</th>
            </tr>
          </thead>
          <tbody>{rows}</tbody>
        </table>
      );
    }
    
    function SearchBar({ filterText, inStockOnly }) {
      return (
        <form>
          <input 
            type="text" 
            value={filterText} 
            placeholder="Search..."/>
          <label>
            <input 
              type="checkbox" 
              checked={inStockOnly} />
            {' '}
            Only show products in stock
          </label>
        </form>
      );
    }
    
    const PRODUCTS = [
      {category: "Fruits", price: "$1", stocked: true, name: "Apple"},
      {category: "Fruits", price: "$1", stocked: true, name: "Dragonfruit"},
      {category: "Fruits", price: "$2", stocked: false, name: "Passionfruit"},
      {category: "Vegetables", price: "$2", stocked: true, name: "Spinach"},
      {category: "Vegetables", price: "$4", stocked: false, name: "Pumpkin"},
      {category: "Vegetables", price: "$1", stocked: true, name: "Peas"}
    ];
    
    export default function App() {
      return <FilterableProductTable products={PRODUCTS} />;
    }
    body {
      padding: 5px
    }
    label {
      display: block;
      margin-top: 5px;
      margin-bottom: 5px;
    }
    th {
      padding-top: 5px;
    }
    td {
      padding: 2px;
    }

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

    Ви додали проп `value` до поля форми без обробника `onChange`. Це призведе до того, що поле стане доступним тільки для читання.

    У наведеній вище пісочниці ProductTable та SearchBar зчитують пропси filterText та inStockOnly для рендерингу таблиці, введення та прапорця. Наприклад, ось як SearchBar заповнює вхідне значення:

    function SearchBar({ filterText, inStockOnly }) {
      return (
        <form>
          <input 
            type="text" 
            value={filterText} 
            placeholder="Search..."/>

    Втім, ви ще не додали жодного коду, який би реагував на дії користувача, наприклад, введення тексту. Це буде вашим останнім кроком.

    Крок 5: Додавання зворотного потоку даних

    Наразі ваш застосунок рендериться коректно з пропсами та станами, що спускаються вниз по ієрархії. Але щоб змінювати стан відповідно до введення користувачем, вам потрібно підтримувати зворотній потік даних: компоненти форми, розташовані глибоко в ієрархії, повинні оновлювати стан у FilterableProductTable.

    React робить цей потік даних явним, але він вимагає трохи більше введення, ніж двостороннє зв'язування даних. Якщо ви спробуєте ввести дані або встановити прапорець у прикладі вище, ви побачите, що React ігнорує ваше введення. Це зроблено навмисно. Написавши <input value={filterText} />, ви встановили проп value як input, щоб він завжди дорівнював стану filterText, переданому з FilterableProductTable. Оскільки стан filterText ніколи не встановлюється, вхідні дані ніколи не змінюються.

    Ви хочете зробити так, щоб щоразу, коли користувач змінює дані у формі, стан оновлювався відповідно до цих змін. Власником стану є FilterableProductTable, тому лише він може викликати setFilterText та setInStockOnly. Щоб дозволити SearchBar оновлювати стан FilterableProductTable, потрібно передати ці функції SearchBar:

    function FilterableProductTable({ products }) {
      const [filterText, setFilterText] = useState('');
      const [inStockOnly, setInStockOnly] = useState(false);
    
      return (
        <div>
          <SearchBar 
            filterText={filterText} 
            inStockOnly={inStockOnly}
            onFilterTextChange={setFilterText}
            onInStockOnlyChange={setInStockOnly} />

    Усередині SearchBar ви додасте обробники подій onChange і встановите з них батьківський стан:

    <input 
      type="text" 
      value={filterText} 
      placeholder="Search..." 
      onChange={(e) => onFilterTextChange(e.target.value)} />

    Тепер застосунок повністю працює!

    import { useState } from 'react';
    
    function FilterableProductTable({ products }) {
      const [filterText, setFilterText] = useState('');
      const [inStockOnly, setInStockOnly] = useState(false);
    
      return (
        <div>
          <SearchBar 
            filterText={filterText} 
            inStockOnly={inStockOnly} 
            onFilterTextChange={setFilterText} 
            onInStockOnlyChange={setInStockOnly} />
          <ProductTable 
            products={products} 
            filterText={filterText}
            inStockOnly={inStockOnly} />
        </div>
      );
    }
    
    function ProductCategoryRow({ category }) {
      return (
        <tr>
          <th colSpan="2">
            {category}
          </th>
        </tr>
      );
    }
    
    function ProductRow({ product }) {
      const name = product.stocked ? product.name :
        <span style={{ color: 'red' }}>
          {product.name}
        </span>;
    
      return (
        <tr>
          <td>{name}</td>
          <td>{product.price}</td>
        </tr>
      );
    }
    
    function ProductTable({ products, filterText, inStockOnly }) {
      const rows = [];
      let lastCategory = null;
    
      products.forEach((product) => {
        if (
          product.name.toLowerCase().indexOf(
            filterText.toLowerCase()
          ) === -1
        ) {
          return;
        }
        if (inStockOnly && !product.stocked) {
          return;
        }
        if (product.category !== lastCategory) {
          rows.push(
            <ProductCategoryRow
              category={product.category}
              key={product.category} />
          );
        }
        rows.push(
          <ProductRow
            product={product}
            key={product.name} />
        );
        lastCategory = product.category;
      });
    
      return (
        <table>
          <thead>
            <tr>
              <th>Name</th>
              <th>Price</th>
            </tr>
          </thead>
          <tbody>{rows}</tbody>
        </table>
      );
    }
    
    function SearchBar({
      filterText,
      inStockOnly,
      onFilterTextChange,
      onInStockOnlyChange
    }) {
      return (
        <form>
          <input 
            type="text" 
            value={filterText} placeholder="Search..." 
            onChange={(e) => onFilterTextChange(e.target.value)} />
          <label>
            <input 
              type="checkbox" 
              checked={inStockOnly} 
              onChange={(e) => onInStockOnlyChange(e.target.checked)} />
            {' '}
            Only show products in stock
          </label>
        </form>
      );
    }
    
    const PRODUCTS = [
      {category: "Fruits", price: "$1", stocked: true, name: "Apple"},
      {category: "Fruits", price: "$1", stocked: true, name: "Dragonfruit"},
      {category: "Fruits", price: "$2", stocked: false, name: "Passionfruit"},
      {category: "Vegetables", price: "$2", stocked: true, name: "Spinach"},
      {category: "Vegetables", price: "$4", stocked: false, name: "Pumpkin"},
      {category: "Vegetables", price: "$1", stocked: true, name: "Peas"}
    ];
    
    export default function App() {
      return <FilterableProductTable products={PRODUCTS} />;
    }
    body {
      padding: 5px
    }
    label {
      display: block;
      margin-top: 5px;
      margin-bottom: 5px;
    }
    th {
      padding: 4px;
    }
    td {
      padding: 2px;
    }

    Про обробку подій та оновлення стану можна дізнатися у розділі Додавання інтерактивності.

    Куди рухатися далі

    Це був дуже короткий вступ до того, як думати про створення компонентів та застосунків за допомогою React. Ви можете розпочати React-проект прямо зараз або зануритися глибше у весь синтаксис, використаний у цьому підручнику.