Як працює key у React: аналіз і нестандартні кейси
Усім привіт! Мене звати Павло, я Front-end Developer у HOLYWATER — продуктовій компанії з екосистеми Genesis. Останні два роки я працюю з React та у вільний час досліджую його можливості.
Мене зацікавив атрибут key, знання про який часто обмежуються кількома тезами: його застосовують у динамічних списках, а як значення не можна використовувати index. Водночас коли натрапляєш на key={`index-${index}`}, стає зрозуміло, що навіть базові тези не всі розуміють правильно.
Це надихнуло мене заглибитися в цю концепцію React та виявити, що key має значно ширші способи використання. У цій статті хочу розібратися, як атрибут key працює під капотом у динамічних списках, а також показати інші його можливості, які допомагають контролювати поведінку React у деяких ситуаціях.
Спершу ми розглянемо, що відбувається на шляху від JSX до реального DOM, зокрема на етапі узгодження (reconciliation). Далі розберемо два окремі механізми використання key: у динамічних списках і за їхніми межами. Наостанок проаналізуємо декілька практичних прикладів, щоб краще зрозуміти потенціал key.
Ця стаття буде цікавою Junior та Middle-розробникам — діліться фідбеком та досвідом у коментарях.
JSX і Virtual DOM: як React обробляє елементи
Як відомо, JSX — це HTML-подібний синтаксис, який React надає нам для зручності (це не більше ніж синтаксичний цукор). Кожен компонент у JSX — чи то базовий (div, h1, p тощо), чи то створений нами кастомний — насправді передбачає виклик функції createElement(). Саме тому такий фрагмент коду:
<div className="element"> Hello, world! </div>
Можна цілком законно замінити на такий:
{React.createElement("div", { className: "element" }, "Hello, world!")}
Ця функція приймає:
- тип елемента («div»);
- обʼєкт props ({ className: «element» });
- контент, який передається як children («Hello, world!»).
Результатом виклику createElement() буде обʼєкт такого типу:
{ '$$typeof': Symbol(react.element), type: 'div', key: null, props: { className: 'element', children: 'Hello, world!' }, ref: null, _owner: FiberNode {} _store: {} }
Якщо замість простого елемента div використати компонент, то обʼєкт, що повертається, матиме такий самий вигляд, але значенням type буде наш компонент.
{ '$$typeof': Symbol(react.element), type: [Function: MyComponent], key: null, props: { children: 'Hello, world!' }, ref: null, _owner: FiberNode {} _store: {} }
Детальний розбір цього обʼєкта — це велика і цікава тема, варта окремої статті. Нас передусім цікавлять властивості type, key та props, але додам декілька слів про інші властивості цього обʼєкта:
- ’$$typeof’: Symbol(react.element) дозволяє React визначати, що цей обʼєкт є валідним React-елементом;
- ref використовують для посилань на DOM-елементи;
- _owner містить інформацію про компонент, який створив цей елемент;
- _store використовують для перевірки пропсів.
Дисклеймер
Вищенаведені приклади обʼєктів не є вичерпними. Є інші варіанти, що можуть охоплювати мемоїзовані компоненти, React Fragment тощо, розгляд яких виходить за межі цієї статті. Також як значення в подальших прикладах для наочності я буду наводити тільки основні властивості, але зазначу, що всі перелічені вище властивості присутні в кожному обʼєкті, який створює функція createElement().
Розглянемо ще декілька прикладів.
Елемент з одним дочірнім елементом такого типу:
<div className="container"> <div>Children 1</div> </div>
Буде записаний в обʼєкт таким чином:
{ type: "div", key: null, props: { className: "container", children: { type: "div", key: null, props: { children: "Children 1" }, ... } }, ... }
Дочірні елементи зберігаються в об’єкті React-елемента як значення властивості children. Це звично під час використання власних компонентів, які отримують props children, але для простих елементів на кшталт div це працює так само.
Таким чином React перетворює весь застосунок на складну ієрархію вкладених та чітко організованих об’єктів, кожен із яких відповідає конкретному вузлу в реальному DOM. Кінцева мета React — створити структуру базових елементів. Якщо він зустрічає елемент, типом якого є компонент (функція), то робить виклик цієї функції. Цей процес буде продовжуватися, доки структура обʼєктів не міститиме лише прості елементи на кшталт div, p, span тощо.
Ба більше, React зберігає цю структуру у двох версіях — поточній (щойно створеній) та попередній. Процес, відомий як reconciliation, передбачає порівняння (diffing) двох екземплярів, щоби визначити, які зміни мають бути виконані в реальному DOM. Сама структура об’єктів називається Virtual DOM.
Повернемося до прикладів і розглянемо, як виглядатиме у Virtual DOM елемент з декількома сусідніми дочірніми елементами:
<div className="container"> <div>Children 1</div> <div>Children 2</div> </div>
У такому разі обʼєкт елемента матиме такий вигляд (приклад спрощено для наочності):
{ type: "div", key: null, props: { className: "container", children: [ { type: "div", key: null, props: ... }, { type: "div", key: null, props: ... } ] }, ... }
Тобто сусідні дочірні елементи зберігаються у властивості children у вигляді масиву.
Думаю, більшість Front-end розробників після того, як побачать останній приклад, захочуть записати його з використанням ітерації:
const items = ['Children 1', 'Children 2']; <div className="container"> {items.map(item => ( <div key={item}>{item}</div> ))} </div>
В результаті обʼєкт елемента має схожу структуру, проте атрибут key матиме одне з двох значень замість null.
{ type: "div", key: null, props: { className: "container", children: [ { type: "div", key: 'Children 1', props: ... }, { type: "div", key: 'Children 2', props: ... } ] }, ... }
Але чи дійсно обʼєкт елемента буде однаковим? Це можна перевірити, розглянувши такий дещо незвичний приклад. Два елементи додамо, використовуючи map(), а третій як окремий сусідній елемент:
const items = ['Children 1', 'Children 2']; <div className="container"> {items.map(item => ( <div key={item}>{item}</div> ))} <div>Children 3</div> </div>
У підсумку обʼєкт елемента матиме такий вигляд. Зверніть увагу на синтаксис children:
{ type: "div", key: null, props: { className: "container", children: [ [ { type: "div", key: null, props: ... }, { type: "div", key: null, props: ... } ], { type: "div", key: null, props: ... } ] } }
Урешті ми отримуємо масив не з трьох, а двох елементів: перший — це динамічний список, згенерований за допомогою методу map(), а другий — статичний елемент, доданий окремо.
Цей приклад ілюструє, як React розрізняє статичні сусідні елементи й динамічні списки, додані за допомогою масиву даних та методу map().
Така поведінка React цілком логічна, бо записані поряд елементи — скільки б їх не було — не можуть бути динамічно змінені через сортування, додавання чи видалення. Вони абсолютно статичні та не можуть зазнати жодних змін, окрім як у самому коді.
Натомість використання масиву даних та методу map() навпаки говорить про те, що цей список буде динамічним. Цілком імовірно, що порядок або кількість елементів у ньому може змінюватись. React прагне робити це з мінімальними оновленнями реального DOM, і атрибут key відіграє в цьому ключову роль.
Роль key у динамічних списках
Філософія React полягає в тому, щоб оновлювати DOM тільки там, де це потрібно. Проте динамічні списки створюють певні складнощі, бо всі елементи списку здаються React однаковими. Вони мають той самий type і відрізняються лише значеннями у props. Як тоді React визначає, який елемент потрібно оновити? Відповідь — за допомогою унікального значення атрибута key.
Уявіть, що ви видалили пʼятий елемент зі списку, в якому key має значення індексу. Ви впевнені, що видалили елемент з індексом 4, але React цього не зрозуміє, бо елемент з таким індексом залишиться. Він побачить видалення останнього елемента списку, а також те, що для інших елементів змінилися значення в обʼєкті props. Натомість унікальний key допоможе React точно зрозуміти, який елемент було видалено зі списку.
Варто зазначити, що для атрибута key можна використовувати будь-яке унікальне значення. Якщо є масив даних, у якому немає спеціально створеного значення id, використовуйте будь-яке інше унікальне значення з масиву. В прикладі вище було взято просто текст. React не обмежує нас у виборі значення та його довжини, головне — унікальність.
Використання індексу масиву як значення key не лише небажане, але й позбавлене сенсу. React і так застосовує індекс за замовчуванням для елементів без key, але це не допомагає правильно відстежувати зміни.
Розглянемо кілька прикладів. Спочатку — приклад використання індексу масиву як значення для key. Зверніть увагу, що відбувається в DevTools.
Як бачите, видалення елемента зі списку чи додавання нового на початку списку провокує оновлення інших елементів. Це відбувається, адже для елементів змінюється індекс у масиві, а отже, і значення key. З погляду React значення key виглядають так само, але змінюється значення props, і відбувається ререндер. Це добре видно, коли елемент видаляється із середини списку — після цього оновлюються лише елементи, які йдуть після нього.
Тепер розглянемо той самий приклад, але з використанням унікального значення key.
Як бачите, додавання чи видалення елементів зі списку жодним чином не впливає на інші елементи списку. Це саме те, чого прагне React і що потрібно розробнику.
Для малих списків вплив використання index буде непомітним, однак для великих масивів з багатьма елементами це може суттєво погіршити продуктивність. Також зверніть увагу, що, коли ми змінюємо порядок, React оновлює всі елементи в списку.
Побутує міф, що додавання key допомагає уникнути зайвого ререндерингу компонентів списку. Але, очевидно, це не так. У разі зміни порядку React ререндерить їх навіть за наявності key, тому його не можна розглядати як альтернативу інструментам мемоїзації. Основне завдання key — це мінімізація непотрібних оновлень DOM саме під час роботи зі списками (додавання, видалення тощо).
Часто розробники використовують масиви та метод map() навіть для статичних списків, які не будуть змінюватися з часом, оскільки це робить код чистішим. У таких кейсах використання індексу як key цілком доцільне і не матиме жодних негативних наслідків. Хоча, на мою думку, краще ніколи не використовувати індекс як значення key.
Розглянемо ще один приклад, чому це погана ідея.
Тут є два майже однакові списки з елементами <input>. Різниця лише в значенні key. Як бачите, з додаванням нового елемента на початку списку приклад з унікальним значенням key працює саме так, як очікувалось, чого не можна сказати про використання індексу.
Як і в минулому прикладі, React не може правильно ідентифікувати окремі елементи, оскільки для кожного з них оновлюється значення key. Для React це виглядає так, наче ми просто додали ще один елемент наприкінці списку. Схематично це можна зобразити в такий спосіб:
{ type: "form", key: null, props: { children: [ { type: "input", key: "0", props: { value: "John Doe", type: "text" } }, { type: "input", key: "1", props: { type: "text" } } ] }, ... }
А ось так це виглядатиме після додавання нового елемента:
{ type: "form", key: null, props: { children: [ { type: "input", key: "0", props: { value: "John Doe", type: "text" } }, { type: "input", key: "1", props: { type: "text" } }, { type: "input", key: "2", props: { type: "text" } }, ] }, ... }
Натомість другий приклад, де ми використали id з унікальним значенням, матиме такий вигляд:
{ type: "form", key: null, props: { children: [ { type: "input", key: "1", props: { value: "John Doe", type: "text" } }, { type: "input", key: "2", props: { type: "text" } } ] }, ... }
Такий — після додавання нового елемента:
{ type: "form", key: null, props: { children: [ { type: "input", key: "3", props: { type: "text" } }, { type: "input", key: "1", props: { value: "John Doe", type: "text" } }, { type: "input", key: "2", props: { type: "text" } }, ] }, ... }
Тому використання індексу як значення атрибута key не просто негативно впливає на продуктивність, але й може спричинити небажану поведінку елементів списку.
Код та демо цього прикладу можна знайти тут: CodePen.
Приклади, які ми розглянули вище, — чи не найпоширеніші кейси використання key. Але цей атрибут може бути корисним за межами динамічних списків. Про це поговоримо далі.
Key поза списками: нестандартні кейси. State reset
Для початку розглянемо простий приклад. Уявімо, що нам необхідно у формі умовно рендерити поле вводу залежно від значення в checkbox. Скажімо, під час реєстрації ми хочемо дізнатися назву компанії, але якщо користувач вказав, що є фрилансером, тоді нам потрібне його імʼя. Спрощений вигляд такої реалізації з неконтрольованим компонентом Input буде виглядати так:
const Example = () => { const [isFreelancer, setIsFreelancer] = useState(false); return ( <> <Checkbox label="I'm freelancer" ... /> {isFreelancer ? ( <Input label="Name" placeholder="Enter your name" /> ) : ( <Input label="Company" placeholder="Enter your company name" /> )} </> ) }
Уявімо, що користувач почав вводити назву компанії, але раптом передумав та клікнув на чекбокс, щоб ввести імʼя.
Попри те, що ми рендеримо інший варіант компонента, введені дані нікуди не зникли; хоча вони нерелевантні, та користувачу доведеться їх видалити вручну. Щоб виправити це, ми можемо замінити умовний рендеринг одного з двох варіантів на рендеринг обох варіантів, але за певних умов. Це буде виглядати так:
const Example = () => { const [isFreelancer, setIsFreelancer] = useState(false); return ( <> <Checkbox label="I'm freelancer" ... /> {isFreelancer ? <Input label="Name" ... /> : null} {!isFreelancer ? <Input label="Company" ... /> : null} </> ) }
Різниця полягає в тому, що React не знає про умовний рендеринг і не відстежує його. Він бачить лише фінальну структуру і порівнює елементи відповідно до їхнього положення в структурі. Тому в першому прикладі children завжди містить масив із двох елементів, а залежно від умови змінюються лише дані в props.
{ type: React.Fragment, key: null, props: { children: [ { type: [Function: Checkbox], key: null, props: ... }, { type: [Function: Input], key: null, props: { label: "Name", ...} }, ] }, ... }
Водночас у другому прикладі масив міститиме три елементи, але один з них завжди буде null. Іншими словами, кожен з варіантів компонента Input матиме своє місце в структурі Virtual DOM, і React зможе їх відрізняти один від одного.
{ type: React.Fragment, key: null, props: { children: [ { type: [Function: Checkbox], key: null, props: ... }, null, { type: [Function: Input], key: null, props: { label: "Company", ...} }, ] }, ... } //or { type: React.Fragment, key: null, props: { children: [ { type: [Function: Checkbox], key: null, props: ... }, { type: [Function: Input], key: null, props: { label: "Name", ...} }, null, ] }, ... }
Але є простіший спосіб виправити цей баг — унікальний атрибут key.
const Example = () => { const [isFreelancer, setIsFreelancer] = useState(false); return ( <> <Checkbox label="I'm freelancer" ... /> {isFreelancer ? ( <Input key="name" label="Name" ... /> ) : ( <Input key="company" label="Company" ... /> )} </> ) }
Такий метод використання атрибута key відомий як state reset. Він жодним чином не суперечить правилам React. Щобільше, це один зі способів скидання стану, який рекомендує React. Детальніше про це можна почитати в цій статті на офіційному сайті.
Розглянемо цей приклад детальніше й подивимось, що відбувається на етапі порівняння обʼєктів поточної та попередньої версії у Virtual DOM. Компоненти Input з різним значенням key у вигляді React-елементів можна відобразити так:
// isFreelancer=true { type: [Function: Input], key: "name", props: { label: "Name", placeholder: "Enter your name", } ... }, // isFreelancer=false { type: [Function: Input], key: "company", props: { label: "Company", placeholder: "Enter your company name", } ... },
Як відбувається порівняння і як саме React визначає, які зміни треба внести в DOM?
Перш за все React порівнює два елементи на рівні референсів, щоб перевірити, чи посилаються обʼєкти на одне і те саме місце в памʼяті. Якщо так, це означає, що елемент не змінився і ніяких дій виконувати не треба. Він залишиться в DOM без змін, і React його пропустить. Водночас якщо елементи не є екземплярами одного обʼєкта, це вказує, що між рендерами відбулися якісь зміни, і React переходить до більш детального порівняння.
Наступний крок — зіставлення значень type. Якщо значення різне, то старий елемент буде видалений з DOM, а новий — доданий. Якщо type однаковий, як у нашому прикладі, то це виглядає як той самий елемент, але React виконує ще одну перевірку — значення key. Це і є той ключовий момент, який розкриває потенціал атрибута key за межами динамічних списків.
Якщо значення key однакове або відсутнє, то виконується ререндер, що і відбулося в першому прикладі. React просто оновив наявний елемент з новими даними, зберігши зв’язок із відповідним елементом у реальному DOM. Це означає, що всі дані, введені користувачем, залишаються прив’язаними до цього DOM-елемента. Тому після кліку на чекбокс користувач бачить не порожнє поле вводу, а введені раніше дані.
Якщо ж значення key відрізняється, то React розцінює це як абсолютно новий елемент і замість ререндеру виконує видалення старого елемента і додавання нового.
На практиці цей приклад малоймовірний, адже в більшості випадків використовуються контрольовані компоненти. Водночас це чудова ілюстрація, як можна певним чином контролювати поведінку React на етапі reconciliation додаванням лише одного атрибута key. Приклад коду — за посиланням.
Використання key з анімаціями
Розглянемо ще один простий, але доволі поширений приклад використання табів. До контенту кожного табу додано класичну анімацію появи, яка спрацьовує щоразу після перемикання. Контент — це єдиний компонент, який приймає зображення, заголовок та текст як props, що змінюються з перемиканням табу.
Нижче на відео можна порівняти поведінку з атрибутом key та без нього. У коді виклик компонента з контентом виглядає ось так.
{hasKey ? ( <ContentCard key={activeContent.id} activeContent={activeContent} /> ) : ( <ContentCard activeContent={activeContent} /> )}
Весь код цього прикладу можна знайти в GitHub-репозиторії.
Як бачите, за наявності атрибута key під час кожного перемикання табу React чітко розуміє, що це різні екземпляри компонента ContentCard, видаляє старий і додає новий замість ререндеру. Внаслідок цього анімація працює в передбачений спосіб. Якщо key відсутній, поведінка компонента вже не така, як очікувалось, бо React оновлює наявний елемент, виконуючи ререндер з новими даними в обʼєкті props. У цей самий момент для компонента починає працювати анімація появи й це виглядає як блимання контенту.
Виправляємо специфічний баг за допомогою key
Наостанок хотів показати ще один специфічний приклад багу, який вдалося легко вирішити за допомогою атрибута key. Він був повʼязаний з кнопкою, відтворювався тільки на iOS-пристроях і тільки в in-app браузері Facebook. Кнопка розміщувалася на статичному сайті (SSG), створеному на Next.js 14, який за замовченням був англійською, а локалізація іншими мовами реалізована через значення пошукового параметра.
Суть багу полягає в тому, що кнопка рендерилася неправильно і містила текст одразу двома мовами. На зображенні чітко видно верхню частину рядка англійською, хоча текст мав би бути іспанською.
Найпростішим способом виправити цей баг було додати до кнопки атрибут key, а як унікальне значення — локаль з пошукових параметрів.
Це доволі специфічний баг, і шансів натрапити на нього не так багато. Однак він чудово ілюструє, наскільки важливо розуміти механізм роботи key у React.
Визначити всі ймовірні ситуації, де state reset може стати в пригоді, неможливо. Однак key — це потужний інструмент, який має бути в арсеналі кожного розробника та іноді може заощадити чимало часу на дебагінгу та пошуку розв’язання специфічних проблем.
Сподіваюсь, стаття була корисною. Головною метою було привернути увагу до атрибута key. Він має широкі можливості, але іноді про нього забувають або хибно асоціюють тільки зі списками.
Я поділився лише двома прикладами, з якими стикався особисто. Проте знаю, що є чимало інших цікавих сценаріїв використання key — навіть як «костиль» для обходу агресивного кешування в Next.js. Буду вдячний, якщо поділитеся своїм досвідом і прикладами в коментарях.
Дякую за ваш час! Слава Україні!
12 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів