Посилання на значення за допомогою рефів

Якщо ви хочете, щоб компонент "запам'ятав" деяку інформацію, але не хочете, щоб ця інформація спричиняла нові рендеринги, ви можете використовувати посилання.

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

Додавання посилання на ваш компонент

Ви можете додати реф до вашого компонента, імпортувавши хук useRef з React:

import { useRef } from 'react';

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

const ref = useRef(0);

useRef повертає об'єкт на зразок цього:

{ 
  current: 0 // The value you passed to useRef
}

Ви можете отримати доступ до поточного значення цього рефа за допомогою властивості ref.current. Це значення навмисно є змінним, тобто ви можете як читати, так і записувати до нього. Це як таємна кишеня вашого компонента, яку React не відстежує. (Саме це робить її "аварійним виходом" з одностороннього потоку даних React - докладніше про це нижче!)

Тут кнопка буде збільшувати ref.current при кожному натисканні:

import { useRef } from 'react';

export default function Counter() {
  let ref = useRef(0);

  function handleClick() {
    ref.current = ref.current + 1;
    alert('You clicked ' + ref.current + ' times!');
  }

  return (
    <button onClick={handleClick}>
      Click me!
    </button>
  );
}

Реф вказує на число, але, як і зі станом, ви можете вказати на будь-що: рядок, об'єкт або навіть функцію. На відміну від стану, реф - це звичайний JavaScript-об'єкт з властивістю current, яку ви можете читати і змінювати.

Зверніть увагу, що компонент не перерендериться з кожним інкрементом. Як і стан, рефи зберігаються React між перерендерингами. Однак, встановлення state перезавантажує компонент. Зміна рефа цього не робить!

Приклад: створення секундоміра

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

const [startTime, setStartTime] = useState(null);
const [now, setNow] = useState(null);

Коли користувач натискає "Пуск", ви будете використовувати setInterval для оновлення часу кожні 10 мілісекунд:

import { useState } from 'react';

export default function Stopwatch() {
  const [startTime, setStartTime] = useState(null);
  const [now, setNow] = useState(null);

  function handleStart() {
    // Start counting.
    setStartTime(Date.now());
    setNow(Date.now());

    setInterval(() => {
      // Update the current time every 10ms.
      setNow(Date.now());
    }, 10);
  }

  let secondsPassed = 0;
  if (startTime != null && now != null) {
    secondsPassed = (now - startTime) / 1000;
  }

  return (
    <>
      <h1>Time passed: {secondsPassed.toFixed(3)}</h1>
      <button onClick={handleStart}>
        Start
      </button>
    </>
  );
}

При натисканні кнопки "Стоп" потрібно скасувати існуючий інтервал, щоб він перестав оновлювати змінну стану now. Це можна зробити за допомогою виклику clearInterval, але потрібно надати йому ідентифікатор інтервалу, який раніше було повернуто викликом setInterval, коли користувач натиснув кнопку "Пуск". Вам потрібно десь зберігати ідентифікатор інтервалу. Оскільки ідентифікатор інтервалу не використовується для рендерингу, ви можете зберігати його у посиланні:

import { useState, useRef } from 'react';

export default function Stopwatch() {
  const [startTime, setStartTime] = useState(null);
  const [now, setNow] = useState(null);
  const intervalRef = useRef(null);

  function handleStart() {
    setStartTime(Date.now());
    setNow(Date.now());

    clearInterval(intervalRef.current);
    intervalRef.current = setInterval(() => {
      setNow(Date.now());
    }, 10);
  }

  function handleStop() {
    clearInterval(intervalRef.current);
  }

  let secondsPassed = 0;
  if (startTime != null && now != null) {
    secondsPassed = (now - startTime) / 1000;
  }

  return (
    <>
      <h1>Time passed: {secondsPassed.toFixed(3)}</h1>
      <button onClick={handleStart}>
        Start
      </button>
      <button onClick={handleStop}>
        Stop
      </button>
    </>
  );
}

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

Різниця між рефами та станом

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

рефи стан
useRef(initialValue) повертає { current: initialValue } useState(initialValue) повертає поточне значення змінної стану і функцію-задавач стану ( [value, setValue])
Не викликає перезавантаження при зміні. Перезавантажує тригери при його зміні.
Змінюваний - ви можете змінювати та оновлювати значення current поза процесом рендерингу. "Незмінний" - вам слід скористатися функцією встановлення стану для зміни змінних стану, щоб поставити в чергу повторне відтворення.
Не слід читати (або записувати) значення поточного під час рендерингу. Ви можете зчитати стан у будь-який час. Однак кожне відображення має власний знімок стану, який не змінюється.

Ось кнопка лічильника, яка реалізована зі станом:

import { useState } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
  }

  return (
    <button onClick={handleClick}>
      You clicked {count} times
    </button>
  );
}

Оскільки значення count виводиться на екран, має сенс використовувати для нього значення стану. Коли значення лічильника встановлюється за допомогою setCount(), React рендерить компонент повторно і екран оновиться, щоб відобразити новий лічильник.

Якби ви спробували реалізувати це за допомогою рефа, React ніколи б не відрендерив компонент, і ви б ніколи не побачили зміни кількості! Подивіться, як натискання цієї кнопки не оновлює її текст:

import { useRef } from 'react';

export default function Counter() {
  let countRef = useRef(0);

  function handleClick() {
    // This doesn't re-render the component!
    countRef.current = countRef.current + 1;
  }

  return (
    <button onClick={handleClick}>
      You clicked {countRef.current} times
    </button>
  );
}

Ось чому читання ref.current під час рендеру призводить до ненадійного коду. Якщо вам це потрібно, використовуйте state.

Як працює useRef всередині?

Хоча і useState, і useRef надаються React, в принципі useRef можна реалізувати поверх useState. Ви можете уявити, що всередині React useRef реалізовано так:

// Inside of React
function useRef(initialValue) {
  const [ref, unused] = useState({ current: initialValue });
  return ref;
}

Під час першого рендеру useRef повертає { current: initialValue }. Цей об'єкт зберігається React, тому під час наступного рендеру буде повернуто той самий об'єкт. Зверніть увагу, що в цьому прикладі не використовується задатчик стану. Він не потрібен, тому що useRef завжди має повертати той самий об'єкт!

React надає вбудовану версію useRef, оскільки вона досить поширена на практиці. Але ви можете думати про неї як про звичайну змінну стану без сеттера. Якщо ви знайомі з об'єктно-орієнтованим програмуванням, рефи можуть нагадати вам поля екземплярів - але замість this.something ви пишете somethingRef.current.

Коли слід використовувати посилання

Зазвичай ви використовуєте реф, коли ваш компонент повинен "вийти за межі" React і зв'язатися із зовнішніми API - часто це API браузера, який не впливає на зовнішній вигляд компонента. Ось декілька з цих рідкісних ситуацій:

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

Найкращі практики для посилань

Дотримання цих принципів зробить ваші компоненти більш передбачуваними:

  • Ставтеся до рефів як до люка евакуації. Рефи корисні, коли ви працюєте з зовнішніми системами або API браузера. Якщо значна частина логіки вашої програми та потоку даних покладається на рефи, можливо, вам варто переглянути свій підхід.
  • Не читати і не писати ref.current під час рендерингу. Якщо якась інформація потрібна під час рендерингу, використовуйте стан. Оскільки React не знає, коли змінюється ref.current, навіть читання його під час рендерингу робить поведінку вашого компонента важко передбачуваною. (Єдиним винятком є код на кшталт if (!ref.current) ref.current = new Thing(), який встановлює реф лише один раз під час першого рендерингу.)

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

ref.current = 5;
console.log(ref.current); // 5

Це тому, що саме посилання є звичайним об'єктом JavaScript, і тому поводиться як такий.

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

Посилання та DOM

Ви можете вказати посилання на будь-яке значення. Однак, найпоширенішим випадком використання рефа є доступ до елемента DOM. Наприклад, це зручно, якщо ви хочете програмно сфокусувати ввід. Коли ви передаєте реф в атрибут ref в JSX, наприклад, <div ref={myRef}>, React помістить відповідний DOM-елемент в myRef.current. Як тільки елемент буде видалено з DOM, React оновить myRef.current на null. Ви можете прочитати більше про це в Маніпулювання DOM за допомогою посилань.

  • Реф - це люк для збереження значень, які не використовуються для рендерингу. Вони не знадобляться вам часто.
  • Реф - це звичайний об'єкт JavaScript з єдиною властивістю current, яку можна прочитати або встановити.
  • Ви можете попросити React надати вам реф, викликавши useRef Hook.
  • Подібно до стану, рефи дозволяють зберігати інформацію між повторними рендерингами компонента.
  • На відміну від cтану, встановлення значення рефа current не спричиняє перезавантаження.
  • Не читайте та не записуйте ref.current під час рендерингу. Це робить ваш компонент важко передбачуваним.

Виправлено несправний вхід до чату

Наберіть повідомлення і натисніть "Надіслати". Ви помітите трисекундну затримку перед тим, як ви побачите сповіщення "Надіслано!". Під час цієї затримки ви можете побачити кнопку "Скасувати". Натисніть її. Кнопка "Скасувати" призначена для того, щоб зупинити появу повідомлення "Надіслано!". Вона робить це шляхом виклику clearTimeout за ідентифікатором таймауту, збереженим під час виконання handleSend. Однак, навіть після натискання кнопки "Скасувати", повідомлення "Надіслано!" все одно з'являється. З'ясуйте, чому це не працює, і виправте це.

Регулярні змінні, такі як let timeoutID, не "виживають" між повторними рендерингами, оскільки кожен рендер запускає ваш компонент (і ініціалізує його змінні) з нуля. Чи слід зберігати ідентифікатор таймауту деінде?

import { useState } from 'react';

export default function Chat() {
  const [text, setText] = useState('');
  const [isSending, setIsSending] = useState(false);
  let timeoutID = null;

  function handleSend() {
    setIsSending(true);
    timeoutID = setTimeout(() => {
      alert('Sent!');
      setIsSending(false);
    }, 3000);
  }

  function handleUndo() {
    setIsSending(false);
    clearTimeout(timeoutID);
  }

  return (
    <>
      <input
        disabled={isSending}
        value={text}
        onChange={e => setText(e.target.value)}
      />
      <button
        disabled={isSending}
        onClick={handleSend}>
        {isSending ? 'Sending...' : 'Send'}
      </button>
      {isSending &&
        <button onClick={handleUndo}>
          Undo
        </button>
      }
    </>
  );
}

Кожного разу, коли ваш компонент повторно рендерить (наприклад, коли ви встановлюєте стан), всі локальні змінні ініціалізуються з нуля. Ось чому ви не можете зберегти ідентифікатор таймауту у локальній змінній на кшталт timeoutID, а потім очікувати, що інший обробник події "побачить" його у майбутньому. Замість цього збережіть його у рефі, який React збереже між рендерами.

import { useState, useRef } from 'react';

export default function Chat() {
  const [text, setText] = useState('');
  const [isSending, setIsSending] = useState(false);
  const timeoutRef = useRef(null);

  function handleSend() {
    setIsSending(true);
    timeoutRef.current = setTimeout(() => {
      alert('Sent!');
      setIsSending(false);
    }, 3000);
  }

  function handleUndo() {
    setIsSending(false);
    clearTimeout(timeoutRef.current);
  }

  return (
    <>
      <input
        disabled={isSending}
        value={text}
        onChange={e => setText(e.target.value)}
      />
      <button
        disabled={isSending}
        onClick={handleSend}>
        {isSending ? 'Sending...' : 'Send'}
      </button>
      {isSending &&
        <button onClick={handleUndo}>
          Undo
        </button>
      }
    </>
  );
}

Виправлено компонент, який не вдається відобразити повторно

Ця кнопка має перемикатися між показом "Увімкнено" та "Вимкнено". Однак, вона завжди показує "Вимкнено". Що не так з цим кодом? Виправте його.

import { useRef } from 'react';

export default function Toggle() {
  const isOnRef = useRef(false);

  return (
    <button onClick={() => {
      isOnRef.current = !isOnRef.current;
    }}>
      {isOnRef.current ? 'On' : 'Off'}
    </button>
  );
}

У цьому прикладі поточне значення рефа використовується для обчислення результату рендерингу: {isOnRef.current ? 'On' : 'Off'}. Це ознака того, що ця інформація не повинна знаходитись у рефі, а повинна бути поміщена у стан. Щоб виправити це, видаліть реф і використовуйте замість нього стан:

import { useState } from 'react';

export default function Toggle() {
  const [isOn, setIsOn] = useState(false);

  return (
    <button onClick={() => {
      setIsOn(!isOn);
    }}>
      {isOn ? 'On' : 'Off'}
    </button>
  );
}

Виправити дебаунсинг

У цьому прикладі всі обробники натискання кнопок є "дебаундованими". Щоб побачити, що це означає, натисніть одну з кнопок. Зверніть увагу, як повідомлення з'явиться через секунду. Якщо ви натиснете кнопку під час очікування повідомлення, таймер буде обнулено. Отже, якщо ви будете швидко натискати одну і ту ж кнопку багато разів, повідомлення з'явиться лише через секунду після того, як ви припините натискати. За допомогою дебаунсингу ви можете затримати певну дію, доки користувач не "припинить робити щось".

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

Чому кнопки заважають одна одній? Знайдіть і виправте проблему.

Змінна ID останнього таймауту є спільною для всіх компонентів DebouncedButton. Ось чому натискання однієї кнопки скидає таймаут іншої кнопки. Чи можна зберігати окремий ідентифікатор таймауту для кожної кнопки?

let timeoutID;

function DebouncedButton({ onClick, children }) {
  return (
    <button onClick={() => {
      clearTimeout(timeoutID);
      timeoutID = setTimeout(() => {
        onClick();
      }, 1000);
    }}>
      {children}
    </button>
  );
}

export default function Dashboard() {
  return (
    <>
      <DebouncedButton
        onClick={() => alert('Spaceship launched!')}
      >
        Launch the spaceship
      </DebouncedButton>
      <DebouncedButton
        onClick={() => alert('Soup boiled!')}
      >
        Boil the soup
      </DebouncedButton>
      <DebouncedButton
        onClick={() => alert('Lullaby sung!')}
      >
        Sing a lullaby
      </DebouncedButton>
    </>
  )
}
button { display: block; margin: 10px; }

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

import { useRef } from 'react';

function DebouncedButton({ onClick, children }) {
  const timeoutRef = useRef(null);
  return (
    <button onClick={() => {
      clearTimeout(timeoutRef.current);
      timeoutRef.current = setTimeout(() => {
        onClick();
      }, 1000);
    }}>
      {children}
    </button>
  );
}

export default function Dashboard() {
  return (
    <>
      <DebouncedButton
        onClick={() => alert('Spaceship launched!')}
      >
        Launch the spaceship
      </DebouncedButton>
      <DebouncedButton
        onClick={() => alert('Soup boiled!')}
      >
        Boil the soup
      </DebouncedButton>
      <DebouncedButton
        onClick={() => alert('Lullaby sung!')}
      >
        Sing a lullaby
      </DebouncedButton>
    </>
  )
}
button { display: block; margin: 10px; }

Прочитати останній стан

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

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

import { useState, useRef } from 'react';

export default function Chat() {
  const [text, setText] = useState('');

  function handleSend() {
    setTimeout(() => {
      alert('Sending: ' + text);
    }, 3000);
  }

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

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

import { useState, useRef } from 'react';

export default function Chat() {
  const [text, setText] = useState('');
  const textRef = useRef(text);

  function handleChange(e) {
    setText(e.target.value);
    textRef.current = e.target.value;
  }

  function handleSend() {
    setTimeout(() => {
      alert('Sending: ' + textRef.current);
    }, 3000);
  }

  return (
    <>
      <input
        value={text}
        onChange={handleChange}
      />
      <button
        onClick={handleSend}>
        Send
      </button>
    </>
  );
}