Збереження та скидання стану

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

  • Коли React вирішує зберегти або скинути стан
  • Як змусити React скинути стан компонента
  • Як ключі та типи впливають на збереження стану

Стан прив'язано до позиції у дереві рендерингу

React збирає дерева візуалізації для структури компонентів у вашому UI.

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

Тут є лише один JSX-тег <Counter />, але він відображається у двох різних позиціях:

import { useState } from 'react';

export default function App() {
  const counter = <Counter />;
  return (
    <div>
      {counter}
      {counter}
    </div>
  );
}

function Counter() {
  const [score, setScore] = useState(0);
  const [hover, setHover] = useState(false);

  let className = 'counter';
  if (hover) {
    className += ' hover';
  }

  return (
    <div
      className={className}
      onPointerEnter={() => setHover(true)}
      onPointerLeave={() => setHover(false)}
    >
      <h1>{score}</h1>
      <button onClick={() => setScore(score + 1)}>
        Add one
      </button>
    </div>
  );
}
label {
  display: block;
  clear: both;
}

.counter {
  width: 100px;
  text-align: center;
  border: 1px solid gray;
  border-radius: 4px;
  padding: 20px;
  margin: 0 20px 20px 0;
  float: left;
}

.hover {
  background: #ffffd8;
}

Ось як це виглядає у вигляді дерева:

Diagram of a tree of React components. The root node is labeled 'div' and has two children. Each of the children are labeled 'Counter' and both contain a state bubble labeled 'count' with value 0.

Дерево реагування

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

У React кожен компонент на екрані має повністю ізольований стан. Наприклад, якщо ви рендерите два компоненти Counter поруч, кожен з них отримає свої власні, незалежні стани score та hover.

Спробуйте клацнути обидва лічильники і помітити, що вони не впливають один на одного:

import { useState } from 'react';

export default function App() {
  return (
    <div>
      <Counter />
      <Counter />
    </div>
  );
}

function Counter() {
  const [score, setScore] = useState(0);
  const [hover, setHover] = useState(false);

  let className = 'counter';
  if (hover) {
    className += ' hover';
  }

  return (
    <div
      className={className}
      onPointerEnter={() => setHover(true)}
      onPointerLeave={() => setHover(false)}
    >
      <h1>{score}</h1>
      <button onClick={() => setScore(score + 1)}>
        Add one
      </button>
    </div>
  );
}
.counter {
  width: 100px;
  text-align: center;
  border: 1px solid gray;
  border-radius: 4px;
  padding: 20px;
  margin: 0 20px 20px 0;
  float: left;
}

.hover {
  background: #ffffd8;
}

Як бачите, при оновленні одного лічильника оновлюється лише стан для цього компонента:

Diagram of a tree of React components. The root node is labeled 'div' and has two children. The left child is labeled 'Counter' and contains a state bubble labeled 'count' with value 0. The right child is labeled 'Counter' and contains a state bubble labeled 'count' with value 1. The state bubble of the right child is highlighted in yellow to indicate its value has updated.

Оновлення стану

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

import { useState } from 'react';

export default function App() {
  const [showB, setShowB] = useState(true);
  return (
    <div>
      <Counter />
      {showB && <Counter />} 
      <label>
        <input
          type="checkbox"
          checked={showB}
          onChange={e => {
            setShowB(e.target.checked)
          }}
        />
        Render the second counter
      </label>
    </div>
  );
}

function Counter() {
  const [score, setScore] = useState(0);
  const [hover, setHover] = useState(false);

  let className = 'counter';
  if (hover) {
    className += ' hover';
  }

  return (
    <div
      className={className}
      onPointerEnter={() => setHover(true)}
      onPointerLeave={() => setHover(false)}
    >
      <h1>{score}</h1>
      <button onClick={() => setScore(score + 1)}>
        Add one
      </button>
    </div>
  );
}
label {
  display: block;
  clear: both;
}

.counter {
  width: 100px;
  text-align: center;
  border: 1px solid gray;
  border-radius: 4px;
  padding: 20px;
  margin: 0 20px 20px 0;
  float: left;
}

.hover {
  background: #ffffd8;
}

Помітьте, як тільки ви припиняєте рендеринг другого лічильника, його стан повністю зникає. Це тому, що коли React видаляє компонент, він знищує його стан.

Diagram of a tree of React components. The root node is labeled 'div' and has two children. The left child is labeled 'Counter' and contains a state bubble labeled 'count' with value 0. The right child is missing, and in its place is a yellow 'poof' image, highlighting the component being deleted from the tree.

Видалення компонента

Якщо поставити галочку "Зобразити другий лічильник", другий Counter та його стан ініціалізується з нуля (score = 0) і додається до DOM.

Diagram of a tree of React components. The root node is labeled 'div' and has two children. The left child is labeled 'Counter' and contains a state bubble labeled 'count' with value 0. The right child is labeled 'Counter' and contains a state bubble labeled 'count' with value 0. The entire right child node is highlighted in yellow, indicating that it was just added to the tree.

Додавання компонента

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

Той самий компонент у тій самій позиції зберігає стан

У цьому прикладі є два різні теги <Counter />:

import { useState } from 'react';

export default function App() {
  const [isFancy, setIsFancy] = useState(false);
  return (
    <div>
      {isFancy ? (
        <Counter isFancy={true} /> 
      ) : (
        <Counter isFancy={false} /> 
      )}
      <label>
        <input
          type="checkbox"
          checked={isFancy}
          onChange={e => {
            setIsFancy(e.target.checked)
          }}
        />
        Use fancy styling
      </label>
    </div>
  );
}

function Counter({ isFancy }) {
  const [score, setScore] = useState(0);
  const [hover, setHover] = useState(false);

  let className = 'counter';
  if (hover) {
    className += ' hover';
  }
  if (isFancy) {
    className += ' fancy';
  }

  return (
    <div
      className={className}
      onPointerEnter={() => setHover(true)}
      onPointerLeave={() => setHover(false)}
    >
      <h1>{score}</h1>
      <button onClick={() => setScore(score + 1)}>
        Add one
      </button>
    </div>
  );
}
label {
  display: block;
  clear: both;
}

.counter {
  width: 100px;
  text-align: center;
  border: 1px solid gray;
  border-radius: 4px;
  padding: 20px;
  margin: 0 20px 20px 0;
  float: left;
}

.fancy {
  border: 5px solid gold;
  color: #ff6767;
}

.hover {
  background: #ffffd8;
}

Коли ви встановлюєте або знімаєте прапорець, стан лічильника не скидається. Незалежно від того, чи isFancy є true, чи false, у вас завжди є <Counter /> як перший нащадок div, повернутий з кореневого App компонента:

Diagram with two sections separated by an arrow transitioning between them. Each section contains a layout of components with a parent labeled 'App' containing a state bubble labeled isFancy. This component has one child labeled 'div', which leads to a prop bubble containing isFancy (highlighted in purple) passed down to the only child. The last child is labeled 'Counter' and contains a state bubble with label 'count' and value 3 in both diagrams. In the left section of the diagram, nothing is highlighted and the isFancy parent state value is false. In the right section of the diagram, the isFancy parent state value has changed to true and it is highlighted in yellow, and so is the props bubble below, which has also changed its isFancy value to true.

Оновлення стану App не скидає Counter, оскільки Counter залишається у тій самій позиції

Це той самий компонент у тій самій позиції, тому з точки зору React це той самий лічильник.

Пам'ятайте, що для React важлива позиція в дереві інтерфейсу користувача, а не в розмітці JSX! Цей компонент має два повернутих речення з різними <Counter /> JSX-тегами всередині та зовні if:

import { useState } from 'react';

export default function App() {
  const [isFancy, setIsFancy] = useState(false);
  if (isFancy) {
    return (
      <div>
        <Counter isFancy={true} />
        <label>
          <input
            type="checkbox"
            checked={isFancy}
            onChange={e => {
              setIsFancy(e.target.checked)
            }}
          />
          Use fancy styling
        </label>
      </div>
    );
  }
  return (
    <div>
      <Counter isFancy={false} />
      <label>
        <input
          type="checkbox"
          checked={isFancy}
          onChange={e => {
            setIsFancy(e.target.checked)
          }}
        />
        Use fancy styling
      </label>
    </div>
  );
}

function Counter({ isFancy }) {
  const [score, setScore] = useState(0);
  const [hover, setHover] = useState(false);

  let className = 'counter';
  if (hover) {
    className += ' hover';
  }
  if (isFancy) {
    className += ' fancy';
  }

  return (
    <div
      className={className}
      onPointerEnter={() => setHover(true)}
      onPointerLeave={() => setHover(false)}
    >
      <h1>{score}</h1>
      <button onClick={() => setScore(score + 1)}>
        Add one
      </button>
    </div>
  );
}
label {
  display: block;
  clear: both;
}

.counter {
  width: 100px;
  text-align: center;
  border: 1px solid gray;
  border-radius: 4px;
  padding: 20px;
  margin: 0 20px 20px 0;
  float: left;
}

.fancy {
  border: 5px solid gold;
  color: #ff6767;
}

.hover {
  background: #ffffd8;
}

Ви можете очікувати, що стан буде скинуто після встановлення прапорця, але цього не станеться! Це тому, що обидва ці теги <Counter /> відображаються в одній позиції. React не знає, де ви розміщуєте умови у вашій функції. Все, що він "бачить" - це дерево, яке ви повертаєте.

В обох випадках компонент App повертає <div> з <Counter /> як першим нащадком. Для React ці два лічильники мають однакову "адресу": перший нащадок першого нащадка кореня. Саме так React зіставляє їх між попереднім і наступним рендерингом, незалежно від того, як ви структуруєте свою логіку.

Різні компоненти у тому самому стані скидання позиції

У цьому прикладі встановлення прапорця замінить <Counter> на <p>:

import { useState } from 'react';

export default function App() {
  const [isPaused, setIsPaused] = useState(false);
  return (
    <div>
      {isPaused ? (
        <p>See you later!</p> 
      ) : (
        <Counter /> 
      )}
      <label>
        <input
          type="checkbox"
          checked={isPaused}
          onChange={e => {
            setIsPaused(e.target.checked)
          }}
        />
        Take a break
      </label>
    </div>
  );
}

function Counter() {
  const [score, setScore] = useState(0);
  const [hover, setHover] = useState(false);

  let className = 'counter';
  if (hover) {
    className += ' hover';
  }

  return (
    <div
      className={className}
      onPointerEnter={() => setHover(true)}
      onPointerLeave={() => setHover(false)}
    >
      <h1>{score}</h1>
      <button onClick={() => setScore(score + 1)}>
        Add one
      </button>
    </div>
  );
}
label {
  display: block;
  clear: both;
}

.counter {
  width: 100px;
  text-align: center;
  border: 1px solid gray;
  border-radius: 4px;
  padding: 20px;
  margin: 0 20px 20px 0;
  float: left;
}

.hover {
  background: #ffffd8;
}

Тут ви перемикаєтеся між різними типами компонентів у тій самій позиції. Спочатку перший нащадок <div> містив Counter. Але коли ви поміняли місцями p, React видалив Counter з дерева інтерфейсу користувача і знищив його стан.

Diagram with three sections, with an arrow transitioning each section in between. The first section contains a React component labeled 'div' with a single child labeled 'Counter' containing a state bubble labeled 'count' with value 3. The middle section has the same 'div' parent, but the child component has now been deleted, indicated by a yellow 'proof' image. The third section has the same 'div' parent again, now with a new child labeled 'p', highlighted in yellow.

Коли Counter змінюється на p, Counter видаляється і додається p

Diagram with three sections, with an arrow transitioning each section in between. The first section contains a React component labeled 'p'. The middle section has the same 'div' parent, but the child component has now been deleted, indicated by a yellow 'proof' image. The third section has the same 'div' parent again, now with a new child labeled 'Counter' containing a state bubble labeled 'count' with value 0, highlighted in yellow.

При зворотному перемиканні видаляється p і додається Counter

.

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

import { useState } from 'react';

export default function App() {
  const [isFancy, setIsFancy] = useState(false);
  return (
    <div>
      {isFancy ? (
        <div>
          <Counter isFancy={true} /> 
        </div>
      ) : (
        <section>
          <Counter isFancy={false} />
        </section>
      )}
      <label>
        <input
          type="checkbox"
          checked={isFancy}
          onChange={e => {
            setIsFancy(e.target.checked)
          }}
        />
        Use fancy styling
      </label>
    </div>
  );
}

function Counter({ isFancy }) {
  const [score, setScore] = useState(0);
  const [hover, setHover] = useState(false);

  let className = 'counter';
  if (hover) {
    className += ' hover';
  }
  if (isFancy) {
    className += ' fancy';
  }

  return (
    <div
      className={className}
      onPointerEnter={() => setHover(true)}
      onPointerLeave={() => setHover(false)}
    >
      <h1>{score}</h1>
      <button onClick={() => setScore(score + 1)}>
        Add one
      </button>
    </div>
  );
}
label {
  display: block;
  clear: both;
}

.counter {
  width: 100px;
  text-align: center;
  border: 1px solid gray;
  border-radius: 4px;
  padding: 20px;
  margin: 0 20px 20px 0;
  float: left;
}

.fancy {
  border: 5px solid gold;
  color: #ff6767;
}

.hover {
  background: #ffffd8;
}

Стан лічильника скидається при натисканні прапорця. Хоча ви рендерите Counter, перший нащадок div змінюється з div на section. Коли дочірнє div було вилучено з DOM, все дерево під ним (включно з Counter та його станом) також було знищено.

Diagram with three sections, with an arrow transitioning each section in between. The first section contains a React component labeled 'div' with a single child labeled 'section', which has a single child labeled 'Counter' containing a state bubble labeled 'count' with value 3. The middle section has the same 'div' parent, but the child components have now been deleted, indicated by a yellow 'proof' image. The third section has the same 'div' parent again, now with a new child labeled 'div', highlighted in yellow, also with a new child labeled 'Counter' containing a state bubble labeled 'count' with value 0, all highlighted in yellow.

Коли section змінюється на div, section видаляється і додається новий div

Diagram with three sections, with an arrow transitioning each section in between. The first section contains a React component labeled 'div' with a single child labeled 'div', which has a single child labeled 'Counter' containing a state bubble labeled 'count' with value 0. The middle section has the same 'div' parent, but the child components have now been deleted, indicated by a yellow 'proof' image. The third section has the same 'div' parent again, now with a new child labeled 'section', highlighted in yellow, also with a new child labeled 'Counter' containing a state bubble labeled 'count' with value 0, all highlighted in yellow.

При зворотному перемиканні видаляється div і додається новий section

Як правило, якщо ви хочете зберегти стан між повторними рендерингами, структура вашого дерева повинна "збігатися" від одного рендерингу до іншого. Якщо структура відрізняється, стан буде знищено, оскільки React знищує стан, коли видаляє компонент з дерева.

Ось чому не слід вкладати визначення функцій компонентів.

Тут визначено функцію компонента MyTextField всередині MyComponent:

import { useState } from 'react';

export default function MyComponent() {
  const [counter, setCounter] = useState(0);

  function MyTextField() {
    const [text, setText] = useState('');

    return (
      <input
        value={text}
        onChange={e => setText(e.target.value)}
      />
    );
  }

  return (
    <>
      <MyTextField />
      <button onClick={() => {
        setCounter(counter + 1)
      }}>Clicked {counter} times</button>
    </>
  );
}

Кожного разу, коли ви натискаєте кнопку, стан вводу зникає! Це відбувається тому, що для кожного рендерингу different MyTextField створюється функція MyComponent. Ви рендерите компонент different у тій самій позиції, тому React скидає всі стани нижче. Це призводить до багів та проблем з продуктивністю. Щоб уникнути цієї проблеми, завжди оголошуйте функції компонентів на верхньому рівні і не вкладайте їх визначення.

Скидання стану на ту саму позицію

За замовчуванням React зберігає стан компонента, поки він залишається в тій самій позиції. Зазвичай, це саме те, що вам потрібно, тому це має сенс як поведінка за замовчуванням. Але іноді вам може знадобитися скинути стан компонента. Розглянемо програму, яка дозволяє двом гравцям стежити за своїми очками під час кожного ходу:

import { useState } from 'react';

export default function Scoreboard() {
  const [isPlayerA, setIsPlayerA] = useState(true);
  return (
    <div>
      {isPlayerA ? (
        <Counter person="Taylor" />
      ) : (
        <Counter person="Sarah" />
      )}
      <button onClick={() => {
        setIsPlayerA(!isPlayerA);
      }}>
        Next player!
      </button>
    </div>
  );
}

function Counter({ person }) {
  const [score, setScore] = useState(0);
  const [hover, setHover] = useState(false);

  let className = 'counter';
  if (hover) {
    className += ' hover';
  }

  return (
    <div
      className={className}
      onPointerEnter={() => setHover(true)}
      onPointerLeave={() => setHover(false)}
    >
      <h1>{person}'s score: {score}</h1>
      <button onClick={() => setScore(score + 1)}>
        Add one
      </button>
    </div>
  );
}
h1 {
  font-size: 18px;
}

.counter {
  width: 100px;
  text-align: center;
  border: 1px solid gray;
  border-radius: 4px;
  padding: 20px;
  margin: 0 20px 20px 0;
}

.hover {
  background: #ffffd8;
}

Наразі, коли ви змінюєте програвач, рахунок зберігається. Два Counter з'являються у тій самій позиції, тому React вважає їх тим самим Counter, чий проп person було змінено.

Але концептуально, у цьому застосунку вони мають бути двома окремими лічильниками. Вони можуть з'являтися в одному місці інтерфейсу, але один з них є лічильником для Тейлора, а інший - для Сари.

Існує два способи скидання стану при перемиканні між ними:

  1. Відображати компоненти у різних позиціях
  2. Надайте кожному компоненту явну ідентичність з key

Варіант 1: Відображення компонента у різних позиціях

Якщо ви хочете, щоб ці два Counter були незалежними, ви можете відрендерити їх у двох різних позиціях:

import { useState } from 'react';

export default function Scoreboard() {
  const [isPlayerA, setIsPlayerA] = useState(true);
  return (
    <div>
      {isPlayerA &&
        <Counter person="Taylor" />
      }
      {!isPlayerA &&
        <Counter person="Sarah" />
      }
      <button onClick={() => {
        setIsPlayerA(!isPlayerA);
      }}>
        Next player!
      </button>
    </div>
  );
}

function Counter({ person }) {
  const [score, setScore] = useState(0);
  const [hover, setHover] = useState(false);

  let className = 'counter';
  if (hover) {
    className += ' hover';
  }

  return (
    <div
      className={className}
      onPointerEnter={() => setHover(true)}
      onPointerLeave={() => setHover(false)}
    >
      <h1>{person}'s score: {score}</h1>
      <button onClick={() => setScore(score + 1)}>
        Add one
      </button>
    </div>
  );
}
h1 {
  font-size: 18px;
}

.counter {
  width: 100px;
  text-align: center;
  border: 1px solid gray;
  border-radius: 4px;
  padding: 20px;
  margin: 0 20px 20px 0;
}

.hover {
  background: #ffffd8;
}
  • Спочатку isPlayerA є true. Отже, перша позиція містить стан Counter, а друга порожня.
  • При натисканні кнопки "Наступний гравець" перша позиція очищується, але друга тепер містить Counter.
Diagram with a tree of React components. The parent is labeled 'Scoreboard' with a state bubble labeled isPlayerA with value 'true'. The only child, arranged to the left, is labeled Counter with a state bubble labeled 'count' and value 0. All of the left child is highlighted in yellow, indicating it was added.

Початковий стан

Diagram with a tree of React components. The parent is labeled 'Scoreboard' with a state bubble labeled isPlayerA with value 'false'. The state bubble is highlighted in yellow, indicating that it has changed. The left child is replaced with a yellow 'poof' image indicating that it has been deleted and there is a new child on the right, highlighted in yellow indicating that it was added. The new child is labeled 'Counter' and contains a state bubble labeled 'count' with value 0.

Натискання кнопки "далі"

Diagram with a tree of React components. The parent is labeled 'Scoreboard' with a state bubble labeled isPlayerA with value 'true'. The state bubble is highlighted in yellow, indicating that it has changed. There is a new child on the left, highlighted in yellow indicating that it was added. The new child is labeled 'Counter' and contains a state bubble labeled 'count' with value 0. The right child is replaced with a yellow 'poof' image indicating that it has been deleted.

Повторне натискання кнопки "далі"

Стани кожного Counter знищуються кожного разу, коли вони видаляються з DOM. Ось чому вони скидаються кожного разу, коли ви натискаєте кнопку.

Це рішення зручне, коли ви маєте лише кілька незалежних компонентів, що рендеряться в одному місці. У цьому прикладі таких компонентів лише два, тому немає потреби рендерити їх окремо у JSX.

Варіант 2: Скидання стану за допомогою ключа

Існує також інший, більш загальний спосіб скидання стану компонента.

Ви могли бачити ключі під час відображення списків. Ключі не тільки для списків! Ви можете використовувати ключі, щоб змусити React розрізняти будь-які компоненти. За замовчуванням, React використовує порядок всередині батька ("перший лічильник", "другий лічильник") для розрізнення компонентів. Але ключі дозволяють вказати React, що це не просто first або second лічильник , а конкретний лічильник - наприклад, Taylor's. Таким чином, React знатиме лічильник Taylor's, де б він не з'явився в дереві!

У цьому прикладі два <Counter /> не мають спільного стану, хоча вони з'являються в одному місці у JSX:

import { useState } from 'react';

export default function Scoreboard() {
  const [isPlayerA, setIsPlayerA] = useState(true);
  return (
    <div>
      {isPlayerA ? (
        <Counter key="Taylor" person="Taylor" />
      ) : (
        <Counter key="Sarah" person="Sarah" />
      )}
      <button onClick={() => {
        setIsPlayerA(!isPlayerA);
      }}>
        Next player!
      </button>
    </div>
  );
}

function Counter({ person }) {
  const [score, setScore] = useState(0);
  const [hover, setHover] = useState(false);

  let className = 'counter';
  if (hover) {
    className += ' hover';
  }

  return (
    <div
      className={className}
      onPointerEnter={() => setHover(true)}
      onPointerLeave={() => setHover(false)}
    >
      <h1>{person}'s score: {score}</h1>
      <button onClick={() => setScore(score + 1)}>
        Add one
      </button>
    </div>
  );
}
h1 {
  font-size: 18px;
}

.counter {
  width: 100px;
  text-align: center;
  border: 1px solid gray;
  border-radius: 4px;
  padding: 20px;
  margin: 0 20px 20px 0;
}

.hover {
  background: #ffffd8;
}

Перемикання між Тейлором та Сарою не зберігає стан. Це тому, що ви дали їм різні ключі :

{isPlayerA ? (
  <Counter key="Taylor" person="Taylor" />
) : (
  <Counter key="Sarah" person="Sarah" />
)}

Вказівка key вказує React використовувати сам key як частину позиції, а не їхній порядок всередині батька. Ось чому, навіть якщо ви відображаєте їх в одному місці в JSX, React бачить їх як два різних лічильника, і тому вони ніколи не матимуть спільного стану. Кожного разу, коли лічильник з'являється на екрані, створюється його стан. Кожного разу, коли він видаляється, його стан знищується. Перемикання між ними скидає їхній стан знову і знову.

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

.

Скидання форми за допомогою ключа

Скидання стану за допомогою ключа особливо корисне при роботі з формами

У цьому застосунку чату компонент <Chat> містить стан введення тексту:

import { useState } from 'react';
import Chat from './Chat.js';
import ContactList from './ContactList.js';

export default function Messenger() {
  const [to, setTo] = useState(contacts[0]);
  return (
    <div>
      <ContactList
        contacts={contacts}
        selectedContact={to}
        onSelect={contact => setTo(contact)}
      />
      <Chat contact={to} />
    </div>
  )
}

const contacts = [
  { id: 0, name: 'Taylor', email: '[email protected]' },
  { id: 1, name: 'Alice', email: '[email protected]' },
  { id: 2, name: 'Bob', email: '[email protected]' }
];
export default function ContactList({
  selectedContact,
  contacts,
  onSelect
}) {
  return (
    <section className="contact-list">
      <ul>
        {contacts.map(contact =>
          <li key={contact.id}>
            <button onClick={() => {
              onSelect(contact);
            }}>
              {contact.name}
            </button>
          </li>
        )}
      </ul>
    </section>
  );
}
import { useState } from 'react';

export default function Chat({ contact }) {
  const [text, setText] = useState('');
  return (
    <section className="chat">
      <textarea
        value={text}
        placeholder={'Chat to ' + contact.name}
        onChange={e => setText(e.target.value)}
      />
      <br />
      <button>Send to {contact.email}</button>
    </section>
  );
}
.chat, .contact-list {
  float: left;
  margin-bottom: 20px;
}
ul, li {
  list-style: none;
  margin: 0;
  padding: 0;
}
li button {
  width: 100px;
  padding: 10px;
  margin-right: 10px;
}
textarea {
  height: 150px;
}

Спробуйте ввести що-небудь на вході, а потім натисніть "Аліса" або "Боб", щоб обрати іншого одержувача. Ви помітите, що стан введення збережено, оскільки <Chat> буде показано у тій самій позиції у дереві.

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

<Chat key={to.id} contact={to} />

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

Тепер перемикання отримувача завжди очищає текстове поле:

import { useState } from 'react';
import Chat from './Chat.js';
import ContactList from './ContactList.js';

export default function Messenger() {
  const [to, setTo] = useState(contacts[0]);
  return (
    <div>
      <ContactList
        contacts={contacts}
        selectedContact={to}
        onSelect={contact => setTo(contact)}
      />
      <Chat key={to.id} contact={to} />
    </div>
  )
}

const contacts = [
  { id: 0, name: 'Taylor', email: '[email protected]' },
  { id: 1, name: 'Alice', email: '[email protected]' },
  { id: 2, name: 'Bob', email: '[email protected]' }
];
export default function ContactList({
  selectedContact,
  contacts,
  onSelect
}) {
  return (
    <section className="contact-list">
      <ul>
        {contacts.map(contact =>
          <li key={contact.id}>
            <button onClick={() => {
              onSelect(contact);
            }}>
              {contact.name}
            </button>
          </li>
        )}
      </ul>
    </section>
  );
}
import { useState } from 'react';

export default function Chat({ contact }) {
  const [text, setText] = useState('');
  return (
    <section className="chat">
      <textarea
        value={text}
        placeholder={'Chat to ' + contact.name}
        onChange={e => setText(e.target.value)}
      />
      <br />
      <button>Send to {contact.email}</button>
    </section>
  );
}
.chat, .contact-list {
  float: left;
  margin-bottom: 20px;
}
ul, li {
  list-style: none;
  margin: 0;
  padding: 0;
}
li button {
  width: 100px;
  padding: 10px;
  margin-right: 10px;
}
textarea {
  height: 150px;
}

Збереження стану для вилучених компонентів

У реальній програмі чату ви, ймовірно, захочете відновити стан введення, коли користувач знову вибере попереднього отримувача. Існує декілька способів зберегти стан "живим" для компонента, який більше не відображається:

  • Ви могли б відобразити усі чати, а не лише поточний, але приховати всі інші за допомогою CSS. Чати не будуть видалені з дерева, тому їх локальний стан буде збережено. Це рішення чудово працює для простих інтерфейсів. Але воно може бути дуже повільним, якщо приховані дерева великі і містять багато DOM-вузлів.
  • Ви можете підняти стан догори і утримувати очікуване повідомлення для кожного отримувача у батьківському компоненті. Таким чином, коли дочірні компоненти буде видалено, це не матиме значення, оскільки саме батьківський компонент зберігає важливу інформацію. Це найпоширеніше рішення.
  • Ви також можете використовувати інше джерело для застосунку до React-стану. Наприклад, ви, ймовірно, хочете, щоб чернетка повідомлення зберігалася, навіть якщо користувач випадково закрили сторінку. Щоб реалізувати це, ви можете використовувати компонент Chat, який ініціалізує свій стан читанням з localStorage, і зберігати чернетки там же.

Незалежно від обраної стратегії, чат з Алісою концептуально відрізняється від чату з Бобом, тому має сенс надавати ключ до дерева <Chat> на основі поточного адресата.

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

Виправлено зникнення вхідного тексту

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

import { useState } from 'react';

export default function App() {
  const [showHint, setShowHint] = useState(false);
  if (showHint) {
    return (
      <div>
        <p><i>Hint: Your favorite city?</i></p>
        <Form />
        <button onClick={() => {
          setShowHint(false);
        }}>Hide hint</button>
      </div>
    );
  }
  return (
    <div>
      <Form />
      <button onClick={() => {
        setShowHint(true);
      }}>Show hint</button>
    </div>
  );
}

function Form() {
  const [text, setText] = useState('');
  return (
    <textarea
      value={text}
      onChange={e => setText(e.target.value)}
    />
  );
}
textarea { display: block; margin: 10px 0; }

Проблема у тому, що Form відображається у різних позиціях. У гілці if він є другим нащадком <div>, а у гілці else - першим. Тому тип компонента у кожній позиції змінюється. Перша позиція змінюється між утриманням p і Form, тоді як друга позиція змінюється між утриманням Form і button. React скидає стан при кожній зміні типу компонента.

Найпростіше рішення - уніфікувати гілки так, щоб Form завжди рендерився в одній позиції:

import { useState } from 'react';

export default function App() {
  const [showHint, setShowHint] = useState(false);
  return (
    <div>
      {showHint &&
        <p><i>Hint: Your favorite city?</i></p>
      }
      <Form />
      {showHint ? (
        <button onClick={() => {
          setShowHint(false);
        }}>Hide hint</button>
      ) : (
        <button onClick={() => {
          setShowHint(true);
        }}>Show hint</button>
      )}
    </div>
  );
}

function Form() {
  const [text, setText] = useState('');
  return (
    <textarea
      value={text}
      onChange={e => setText(e.target.value)}
    />
  );
}
textarea { display: block; margin: 10px 0; }

Технічно, ви також можете додати null перед <Form /> у гілці else для відповідності структурі гілки if:

import { useState } from 'react';

export default function App() {
  const [showHint, setShowHint] = useState(false);
  if (showHint) {
    return (
      <div>
        <p><i>Hint: Your favorite city?</i></p>
        <Form />
        <button onClick={() => {
          setShowHint(false);
        }}>Hide hint</button>
      </div>
    );
  }
  return (
    <div>
      {null}
      <Form />
      <button onClick={() => {
        setShowHint(true);
      }}>Show hint</button>
    </div>
  );
}

function Form() {
  const [text, setText] = useState('');
  return (
    <textarea
      value={text}
      onChange={e => setText(e.target.value)}
    />
  );
}
textarea { display: block; margin: 10px 0; }

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

Поміняти місцями два поля форми

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

Це майже працює, але є помилка. Якщо ви заповните поле "Ім'я" і поставите галочку, текст залишиться у першому полі (яке зараз є "Прізвище"). Виправте це так, щоб текст введення такожпереміщався, коли ви змінюєте порядок.

Здається, що для цих полів недостатньо їх позиції в межах батьківського. Чи можна якось сказати React, як узгодити стан між повторними рендерингами?

import { useState } from 'react';

export default function App() {
  const [reverse, setReverse] = useState(false);
  let checkbox = (
    <label>
      <input
        type="checkbox"
        checked={reverse}
        onChange={e => setReverse(e.target.checked)}
      />
      Reverse order
    </label>
  );
  if (reverse) {
    return (
      <>
        <Field label="Last name" /> 
        <Field label="First name" />
        {checkbox}
      </>
    );
  } else {
    return (
      <>
        <Field label="First name" /> 
        <Field label="Last name" />
        {checkbox}
      </>
    );    
  }
}

function Field({ label }) {
  const [text, setText] = useState('');
  return (
    <label>
      {label}:{' '}
      <input
        type="text"
        value={text}
        placeholder={label}
        onChange={e => setText(e.target.value)}
      />
    </label>
  );
}
label { display: block; margin: 10px 0; }

Дайте ключ обом компонентам <Field> в обох гілках if та else. Це вказує React, як "підібрати" правильний стан для будь-якої з гілок <Field>, навіть якщо їхній порядок у батьківській гілці змінюється:

import { useState } from 'react';

export default function App() {
  const [reverse, setReverse] = useState(false);
  let checkbox = (
    <label>
      <input
        type="checkbox"
        checked={reverse}
        onChange={e => setReverse(e.target.checked)}
      />
      Reverse order
    </label>
  );
  if (reverse) {
    return (
      <>
        <Field key="lastName" label="Last name" /> 
        <Field key="firstName" label="First name" />
        {checkbox}
      </>
    );
  } else {
    return (
      <>
        <Field key="firstName" label="First name" /> 
        <Field key="lastName" label="Last name" />
        {checkbox}
      </>
    );    
  }
}

function Field({ label }) {
  const [text, setText] = useState('');
  return (
    <label>
      {label}:{' '}
      <input
        type="text"
        value={text}
        placeholder={label}
        onChange={e => setText(e.target.value)}
      />
    </label>
  );
}
label { display: block; margin: 10px 0; }

Скинути форму деталізації

Це список контактів, який можна редагувати. Ви можете відредагувати дані вибраного контакту, а потім або натиснути "Зберегти", щоб оновити їх, або "Скинути", щоб скасувати зміни.

При виборі іншого контакту (наприклад, Аліса) стан оновлюється, але форма продовжує показувати дані попереднього контакту. Виправте це так, щоб форма перезавантажувалася при зміні вибраного контакту.

import { useState } from 'react';
import ContactList from './ContactList.js';
import EditContact from './EditContact.js';

export default function ContactManager() {
  const [
    contacts,
    setContacts
  ] = useState(initialContacts);
  const [
    selectedId,
    setSelectedId
  ] = useState(0);
  const selectedContact = contacts.find(c =>
    c.id === selectedId
  );

  function handleSave(updatedData) {
    const nextContacts = contacts.map(c => {
      if (c.id === updatedData.id) {
        return updatedData;
      } else {
        return c;
      }
    });
    setContacts(nextContacts);
  }

  return (
    <div>
      <ContactList
        contacts={contacts}
        selectedId={selectedId}
        onSelect={id => setSelectedId(id)}
      />
      <hr />
      <EditContact
        initialData={selectedContact}
        onSave={handleSave}
      />
    </div>
  )
}

const initialContacts = [
  { id: 0, name: 'Taylor', email: '[email protected]' },
  { id: 1, name: 'Alice', email: '[email protected]' },
  { id: 2, name: 'Bob', email: '[email protected]' }
];
export default function ContactList({
  contacts,
  selectedId,
  onSelect
}) {
  return (
    <section>
      <ul>
        {contacts.map(contact =>
          <li key={contact.id}>
            <button onClick={() => {
              onSelect(contact.id);
            }}>
              {contact.id === selectedId ?
                <b>{contact.name}</b> :
                contact.name
              }
            </button>
          </li>
        )}
      </ul>
    </section>
  );
}
import { useState } from 'react';

export default function EditContact({ initialData, onSave }) {
  const [name, setName] = useState(initialData.name);
  const [email, setEmail] = useState(initialData.email);
  return (
    <section>
      <label>
        Name:{' '}
        <input
          type="text"
          value={name}
          onChange={e => setName(e.target.value)}
        />
      </label>
      <label>
        Email:{' '}
        <input
          type="email"
          value={email}
          onChange={e => setEmail(e.target.value)}
        />
      </label>
      <button onClick={() => {
        const updatedData = {
          id: initialData.id,
          name: name,
          email: email
        };
        onSave(updatedData);
      }}>
        Save
      </button>
      <button onClick={() => {
        setName(initialData.name);
        setEmail(initialData.email);
      }}>
        Reset
      </button>
    </section>
  );
}
ul, li {
  list-style: none;
  margin: 0;
  padding: 0;
}
li { display: inline-block; }
li button {
  padding: 10px;
}
label {
  display: block;
  margin: 10px 0;
}
button {
  margin-right: 10px;
  margin-bottom: 10px;
}

Передайте key={selectedId} компоненту EditContact. Таким чином, перемикання між різними контактами призведе до скидання форми:

import { useState } from 'react';
import ContactList from './ContactList.js';
import EditContact from './EditContact.js';

export default function ContactManager() {
  const [
    contacts,
    setContacts
  ] = useState(initialContacts);
  const [
    selectedId,
    setSelectedId
  ] = useState(0);
  const selectedContact = contacts.find(c =>
    c.id === selectedId
  );

  function handleSave(updatedData) {
    const nextContacts = contacts.map(c => {
      if (c.id === updatedData.id) {
        return updatedData;
      } else {
        return c;
      }
    });
    setContacts(nextContacts);
  }

  return (
    <div>
      <ContactList
        contacts={contacts}
        selectedId={selectedId}
        onSelect={id => setSelectedId(id)}
      />
      <hr />
      <EditContact
        key={selectedId}
        initialData={selectedContact}
        onSave={handleSave}
      />
    </div>
  )
}

const initialContacts = [
  { id: 0, name: 'Taylor', email: '[email protected]' },
  { id: 1, name: 'Alice', email: '[email protected]' },
  { id: 2, name: 'Bob', email: '[email protected]' }
];
export default function ContactList({
  contacts,
  selectedId,
  onSelect
}) {
  return (
    <section>
      <ul>
        {contacts.map(contact =>
          <li key={contact.id}>
            <button onClick={() => {
              onSelect(contact.id);
            }}>
              {contact.id === selectedId ?
                <b>{contact.name}</b> :
                contact.name
              }
            </button>
          </li>
        )}
      </ul>
    </section>
  );
}
import { useState } from 'react';

export default function EditContact({ initialData, onSave }) {
  const [name, setName] = useState(initialData.name);
  const [email, setEmail] = useState(initialData.email);
  return (
    <section>
      <label>
        Name:{' '}
        <input
          type="text"
          value={name}
          onChange={e => setName(e.target.value)}
        />
      </label>
      <label>
        Email:{' '}
        <input
          type="email"
          value={email}
          onChange={e => setEmail(e.target.value)}
        />
      </label>
      <button onClick={() => {
        const updatedData = {
          id: initialData.id,
          name: name,
          email: email
        };
        onSave(updatedData);
      }}>
        Save
      </button>
      <button onClick={() => {
        setName(initialData.name);
        setEmail(initialData.email);
      }}>
        Reset
      </button>
    </section>
  );
}
ul, li {
  list-style: none;
  margin: 0;
  padding: 0;
}
li { display: inline-block; }
li button {
  padding: 10px;
}
label {
  display: block;
  margin: 10px 0;
}
button {
  margin-right: 10px;
  margin-bottom: 10px;
}

Очистити зображення під час його завантаження

При натисканні кнопки "Далі" браузер почне завантажувати наступне зображення. Однак, оскільки воно відображається у тому ж тегу <img>, за замовчуванням ви бачитимете попереднє зображення, доки не завантажиться наступне. Це може бути небажано, якщо вам важливо, щоб текст завжди відповідав зображенню. Змініть це так, щоб при натисканні кнопки "Далі" попереднє зображення одразу зникало.

Чи можна змусити React перестворювати DOM замість того, щоб використовувати його повторно?

import { useState } from 'react';

export default function Gallery() {
  const [index, setIndex] = useState(0);
  const hasNext = index < images.length - 1;

  function handleClick() {
    if (hasNext) {
      setIndex(index + 1);
    } else {
      setIndex(0);
    }
  }

  let image = images[index];
  return (
    <>
      <button onClick={handleClick}>
        Next
      </button>
      <h3>
        Image {index + 1} of {images.length}
      </h3>
      <img src={image.src} />
      <p>
        {image.place}
      </p>
    </>
  );
}

let images = [{
  place: 'Penang, Malaysia',
  src: 'https://i.imgur.com/FJeJR8M.jpg'
}, {
  place: 'Lisbon, Portugal',
  src: 'https://i.imgur.com/dB2LRbj.jpg'
}, {
  place: 'Bilbao, Spain',
  src: 'https://i.imgur.com/z08o2TS.jpg'
}, {
  place: 'Valparaíso, Chile',
  src: 'https://i.imgur.com/Y3utgTi.jpg'
}, {
  place: 'Schwyz, Switzerland',
  src: 'https://i.imgur.com/JBbMpWY.jpg'
}, {
  place: 'Prague, Czechia',
  src: 'https://i.imgur.com/QwUKKmF.jpg'
}, {
  place: 'Ljubljana, Slovenia',
  src: 'https://i.imgur.com/3aIiwfm.jpg'
}];
img { width: 150px; height: 150px; }

Ви можете вказати ключ до тегу <img>. Коли цей key змінюється, React повторно створює DOM-вузол <img> з нуля. Це спричиняє короткий спалах при завантаженні кожного зображення, тому це не те, що ви хотіли б робити для кожного зображення у вашому застосунку. Але це має сенс, якщо ви хочете, щоб зображення завжди відповідало тексту.

import { useState } from 'react';

export default function Gallery() {
  const [index, setIndex] = useState(0);
  const hasNext = index < images.length - 1;

  function handleClick() {
    if (hasNext) {
      setIndex(index + 1);
    } else {
      setIndex(0);
    }
  }

  let image = images[index];
  return (
    <>
      <button onClick={handleClick}>
        Next
      </button>
      <h3>
        Image {index + 1} of {images.length}
      </h3>
      <img key={image.src} src={image.src} />
      <p>
        {image.place}
      </p>
    </>
  );
}

let images = [{
  place: 'Penang, Malaysia',
  src: 'https://i.imgur.com/FJeJR8M.jpg'
}, {
  place: 'Lisbon, Portugal',
  src: 'https://i.imgur.com/dB2LRbj.jpg'
}, {
  place: 'Bilbao, Spain',
  src: 'https://i.imgur.com/z08o2TS.jpg'
}, {
  place: 'Valparaíso, Chile',
  src: 'https://i.imgur.com/Y3utgTi.jpg'
}, {
  place: 'Schwyz, Switzerland',
  src: 'https://i.imgur.com/JBbMpWY.jpg'
}, {
  place: 'Prague, Czechia',
  src: 'https://i.imgur.com/QwUKKmF.jpg'
}, {
  place: 'Ljubljana, Slovenia',
  src: 'https://i.imgur.com/3aIiwfm.jpg'
}];
img { width: 150px; height: 150px; }

Виправлено неправильний стан у списку

У цьому списку кожен Contact має стан, який визначає, чи було для нього натиснуто кнопку "Показати email". Натисніть "Показати email" для Аліси, а потім встановіть прапорець "Показувати у зворотному порядку". Ви помітите, що саме лист Тейлор розгорнуто, а лист Аліси, який перемістився вниз, виглядає згорнутим.

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

import { useState } from 'react';
import Contact from './Contact.js';

export default function ContactList() {
  const [reverse, setReverse] = useState(false);

  const displayedContacts = [...contacts];
  if (reverse) {
    displayedContacts.reverse();
  }

  return (
    <>
      <label>
        <input
          type="checkbox"
          value={reverse}
          onChange={e => {
            setReverse(e.target.checked)
          }}
        />{' '}
        Show in reverse order
      </label>
      <ul>
        {displayedContacts.map((contact, i) =>
          <li key={i}>
            <Contact contact={contact} />
          </li>
        )}
      </ul>
    </>
  );
}

const contacts = [
  { id: 0, name: 'Alice', email: '[email protected]' },
  { id: 1, name: 'Bob', email: '[email protected]' },
  { id: 2, name: 'Taylor', email: '[email protected]' }
];
import { useState } from 'react';

export default function Contact({ contact }) {
  const [expanded, setExpanded] = useState(false);
  return (
    <>
      <p><b>{contact.name}</b></p>
      {expanded &&
        <p><i>{contact.email}</i></p>
      }
      <button onClick={() => {
        setExpanded(!expanded);
      }}>
        {expanded ? 'Hide' : 'Show'} email
      </button>
    </>
  );
}
ul, li {
  list-style: none;
  margin: 0;
  padding: 0;
}
li {
  margin-bottom: 20px;
}
label {
  display: block;
  margin: 10px 0;
}
button {
  margin-right: 10px;
  margin-bottom: 10px;
}

Проблема у тому, що у цьому прикладі було використано index як ключ:

{displayedContacts.map((contact, i) =>
  <li key={i}>

Однак, ви хочете, щоб стан був пов'язаний з кожним конкретним контактом.

Використання ідентифікатора контакту як ключа натомість виправляє проблему:

import { useState } from 'react';
import Contact from './Contact.js';

export default function ContactList() {
  const [reverse, setReverse] = useState(false);

  const displayedContacts = [...contacts];
  if (reverse) {
    displayedContacts.reverse();
  }

  return (
    <>
      <label>
        <input
          type="checkbox"
          value={reverse}
          onChange={e => {
            setReverse(e.target.checked)
          }}
        />{' '}
        Show in reverse order
      </label>
      <ul>
        {displayedContacts.map(contact =>
          <li key={contact.id}>
            <Contact contact={contact} />
          </li>
        )}
      </ul>
    </>
  );
}

const contacts = [
  { id: 0, name: 'Alice', email: '[email protected]' },
  { id: 1, name: 'Bob', email: '[email protected]' },
  { id: 2, name: 'Taylor', email: '[email protected]' }
];
import { useState } from 'react';

export default function Contact({ contact }) {
  const [expanded, setExpanded] = useState(false);
  return (
    <>
      <p><b>{contact.name}</b></p>
      {expanded &&
        <p><i>{contact.email}</i></p>
      }
      <button onClick={() => {
        setExpanded(!expanded);
      }}>
        {expanded ? 'Hide' : 'Show'} email
      </button>
    </>
  );
}
ul, li {
  list-style: none;
  margin: 0;
  padding: 0;
}
li {
  margin-bottom: 20px;
}
label {
  display: block;
  margin: 10px 0;
}
button {
  margin-right: 10px;
  margin-bottom: 10px;
}

Стан пов'язано з позицією у дереві. Ключ дозволяє вказати іменовану позицію замість того, щоб покладатися на порядок.