Вибір структури стану
Структура стану може утворювати разючу різницю між компонентом, який приємно змінювати та зневаджувати, та компонентом, який є постійним джерелом дефектів. Ось кілька порад, які варто обдумати при структуруванні стану.
You will learn
- Коли використовувати одну змінну стану, а коли – декілька
- Чого слід уникати при організації стану
- Як виправити поширені помилки за допомогою доброї структури стану
Принципи структурування стану
Коли ви пишете компонент, що містить певний стан, доводиться приймати рішення про те, скільки змінних стану використати і якою повинна бути форма їхніх даних. Попри те, що можна написати коректну програму навіть з субоптимальною структурою стану, є кілька принципів, які можуть привести до кращих рішень:
- Групувати споріднені частини стану. Якщо дві чи більше змінні стану завжди оновлюватимуться водночас, можливо, їх краще об’єднати в одну змінну стану.
- Уникати суперечностей стану. Коли стан структурований так, що кілька його частин можуть суперечити та “не походжуватися” одне з одним, це утворює простір для помилок. Цього слід уникати.
- Уникати надлишкового стану. Якщо якусь інформацію під час рендерингу можна обчислити на основі пропсів компонента чи його наявних змінних стану, не слід додавати таку інформацію до стану цього компонента.
- Уникати дублювання у стані. Коли одні й ті ж дані дублюються в різних змінних стану, або всередині вкладених об’єктів, то складно підтримувати їхню синхронізацію. Позбавляйтеся дублювання, коли можете.
- Уникати стану з глибокою вкладеністю. Глибоко ієрархічний стан не дуже зручно оновлювати. Коли це можливо, віддавайте перевагу пласкому стану.
Мета цих принципів – щоб стан було легко оновлювати без додавання помилок. Вилучення зі стану надлишкових і продубльованих даних допомагає пересвідчитися, що всі частини синхронізовані одна з одною. Це схоже на те, як інженер баз даних міг би “нормалізуватИ” структуру бази даних, щоб знизити шанс появи дефектів. Перефразовуючи Альберта Ейнштейна, “Роби свій стан простим, як можливо, – але не простіше цього.”
Тепер погляньмо, як ці принципи застосовуються на практиці.
Групувати споріднений стан
Іноді може бути непевність щодо використання однієї або кількох змінних стану.
Чи варто зробити так?
const [x, setX] = useState(0);
const [y, setY] = useState(0);
Або так?
const [position, setPosition] = useState({ x: 0, y: 0 });
Технічно можна зробити і так, і так. Але якщо якісь дві змінні стану завжди змнюються разом, можливо, краще об’єднати їх в одну змінну стану. Тоді ви не забудете завжди підтримувати їхню синхронізацію, як у цьому прикладі, де рухання курсора оновлює обидві координати червоної крапки:
import { useState } from 'react'; export default function MovingDot() { const [position, setPosition] = useState({ x: 0, y: 0 }); return ( <div onPointerMove={e => { setPosition({ x: e.clientX, y: e.clientY }); }} style={{ position: 'relative', width: '100vw', height: '100vh', }}> <div style={{ position: 'absolute', backgroundColor: 'red', borderRadius: '50%', transform: `translate(${position.x}px, ${position.y}px)`, left: -10, top: -10, width: 20, height: 20, }} /> </div> ) }
Ще одна ситуація, коли дані групують в об’єкт чи масив, – це коли невідомо, скільки порцій стану знадобиться. Наприклад, це допомагає, коли є форма, куди користувач може додати власні поля.
Уникати суперечностей стану
Ось форма для відгуків на готель, що має змінні стану isSending
і isSent
:
import { useState } from 'react'; export default function FeedbackForm() { const [text, setText] = useState(''); const [isSending, setIsSending] = useState(false); const [isSent, setIsSent] = useState(false); async function handleSubmit(e) { e.preventDefault(); setIsSending(true); await sendMessage(text); setIsSending(false); setIsSent(true); } if (isSent) { return <h1>Дякуємо за відгук!</h1> } return ( <form onSubmit={handleSubmit}> <p>Як вам було зупинитися в Поні-стрибунці?</p> <textarea disabled={isSending} value={text} onChange={e => setText(e.target.value)} /> <br /> <button disabled={isSending} type="submit" > Надіслати </button> {isSending && <p>Надсилається...</p>} </form> ); } // Удаємо, ніби надсилаємо повідомлення. function sendMessage(text) { return new Promise(resolve => { setTimeout(resolve, 2000); }); }
Попри те, що цей код працює, він залишає простір для “неможливих” станів. Наприклад, якщо забути викликати setIsSent
і setIsSending
разом, то можна опитися в ситуації, коли і isSending
, і isSent
мають значення true
водночас. Чим складніший компонент, тим важче зрозуміти, що трапилося.
Оскільки isSending
та isSent
ніколи не повинні мати значення true
водночас, краще замінити їх однією змінною стану status
, яка може перебувати в одному з трьох валідних станів: 'typing'
(початковий), 'sending'
і 'sent'
:
import { useState } from 'react'; export default function FeedbackForm() { const [text, setText] = useState(''); const [status, setStatus] = useState('typing'); async function handleSubmit(e) { e.preventDefault(); setStatus('sending'); await sendMessage(text); setStatus('sent'); } const isSending = status === 'sending'; const isSent = status === 'sent'; if (isSent) { return <h1>Дякуємо за відгук!</h1> } return ( <form onSubmit={handleSubmit}> <p>Як вам було зупинитися в Поні-стрибунці?</p> <textarea disabled={isSending} value={text} onChange={e => setText(e.target.value)} /> <br /> <button disabled={isSending} type="submit" > Надіслати </button> {isSending && <p>Надсилається...</p>} </form> ); } // Удаємо, ніби надсилаємо повідомлення. function sendMessage(text) { return new Promise(resolve => { setTimeout(resolve, 2000); }); }
Для кращої прочитності можна все ж додати кілька сталих:
const isSending = status === 'sending';
const isSent = status === 'sent';
Але вони не є змінними стану, тож немає потреби турбуватися про те, що вони розсинхронізуються одна з одною.
Уникати надлишковий стану
Якщо можна обчислити якусь інформацію під час рендерингу, на основі пропсів компонента або наявних змінних стану, не слід додавати цю інформацію до стану компонента.
Для прикладу – ця форма. Вона працює, але чи зможете ви знайти в ній надлишковий стан?
import { useState } from 'react'; export default function Form() { const [firstName, setFirstName] = useState(''); const [lastName, setLastName] = useState(''); const [fullName, setFullName] = useState(''); function handleFirstNameChange(e) { setFirstName(e.target.value); setFullName(e.target.value + ' ' + lastName); } function handleLastNameChange(e) { setLastName(e.target.value); setFullName(firstName + ' ' + e.target.value); } return ( <> <h2>Зареєструймо вас</h2> <label> Ім'я:{' '} <input value={firstName} onChange={handleFirstNameChange} /> </label> <label> Прізвище:{' '} <input value={lastName} onChange={handleLastNameChange} /> </label> <p> Ваш квиток буде видано на ім'я: <b>{fullName}</b> </p> </> ); }
У цій формі три змінні стану: firstName
, lastName
і fullName
. Проте fullName
– надлишкова. Змінну fullName
завжди можна обчислити під час рендеру на основі firstName
і lastName
, тож її слід вилучити зі стану.
Ось як це робиться:
import { useState } from 'react'; export default function Form() { const [firstName, setFirstName] = useState(''); const [lastName, setLastName] = useState(''); const fullName = firstName + ' ' + lastName; function handleFirstNameChange(e) { setFirstName(e.target.value); } function handleLastNameChange(e) { setLastName(e.target.value); } return ( <> <h2>Зареєструймо вас</h2> <label> Ім'я:{' '} <input value={firstName} onChange={handleFirstNameChange} /> </label> <label> Прізвище:{' '} <input value={lastName} onChange={handleLastNameChange} /> </label> <p> Ваш квиток буде видано на ім'я: <b>{fullName}</b> </p> </> ); }
Тут fullName
– це не змінна стану. Натомість це значення обчислюється під час рендеру:
const fullName = firstName + ' ' + lastName;
Як наслідок, обробникам змін не потрібно робити нічого особливого для його оновлення. Викликавши setFirstName
або setLastName
, ви запускаєте повторний рендер, а тоді наступне значення fullName
обчислюється на основі свіжих даних.
Deep Dive
Поширений приклад надлишкового стану – код, схожий на цей:
function Message({ messageColor }) {
const [color, setColor] = useState(messageColor);
Тут змінна стану color
ініціалізується пропом messageColor
. Проблема полягає в тому, що якщо батьківський компонент передасть інше значення messageColor
пізніше (наприклад, 'red'
замість 'blue'
), то змінна стану color
не оновиться! Стан ініціалізується лише під час першого рендеру.
Саме тому “віддзеркалення” якогось пропу на змінну стану може приводити до спантеличеності. Замість цього використовуйте у своєму коді проп messageColor
безпосередньо. Якщо хочете надати йому коротше ім’я, створіть сталу:
function Message({ messageColor }) {
const color = messageColor;
Так він не розсинхронізується з пропом, переданим з батьківського компонента.
”Віддзеркалення” пропсів у стан має зміст лише тоді, коли ви хочете ігнорувати всі оновлення конкретного пропа. Прийнято починати назву такого пропа з initial
або default
, аби прояснити, що нові значення ігноруються:
function Message({ initialColor }) {
// Змінна стану `color` зберігає *перше* значення `initialColor`.
// Наступні зміни пропа `initialColor` ігноруються.
const [color, setColor] = useState(initialColor);
Уникати дублювання стану
Цей компонент списку меню дає змогу обрати один дорожній перекус з кількох:
import { useState } from 'react'; const initialItems = [ { title: 'пиріжок з горохом', id: 0 }, { title: 'горішки', id: 1 }, { title: 'зерновий батончик', id: 2 }, ]; export default function Menu() { const [items, setItems] = useState(initialItems); const [selectedItem, setSelectedItem] = useState( items[0] ); return ( <> <h2>Який вам перекус?</h2> <ul> {items.map(item => ( <li key={item.id}> {item.title} {' '} <button onClick={() => { setSelectedItem(item); }}>Оберіть</button> </li> ))} </ul> <p>Ви обрали {selectedItem.title}.</p> </> ); }
Наразі він зберігає обраний елемент як об’єкт у змінній стану selectedItem
. Проте це не надто тішить: вміст selectedItem
– той самий об’єкт, що присутній в списку items
. Це означає, що інформація про сам елемент дублюється у двох місцях.
Чому це проблема? Зробимо кожний з елементів доступним для редагування:
import { useState } from 'react'; const initialItems = [ { title: 'пиріжок з горохом', id: 0 }, { title: 'горішки', id: 1 }, { title: 'зерновий батончик', id: 2 }, ]; export default function Menu() { const [items, setItems] = useState(initialItems); const [selectedItem, setSelectedItem] = useState( items[0] ); function handleItemChange(id, e) { setItems(items.map(item => { if (item.id === id) { return { ...item, title: e.target.value, }; } else { return item; } })); } return ( <> <h2>Який вам перекус?</h2> <ul> {items.map((item, index) => ( <li key={item.id}> <input value={item.title} onChange={e => { handleItemChange(item.id, e) }} /> {' '} <button onClick={() => { setSelectedItem(item); }}>Оберіть</button> </li> ))} </ul> <p>Ви обрали {selectedItem.title}.</p> </> ); }
Зверніть увагу, що якщо спершу клацнути на елементі “Оберіть”, а потім відредагувати його, то поле оновлюється, але на підпис унизу це редагування не впливає. Так відбувається, тому що стан дублюється, і ви забули оновити selectedItem
.
Попри те, що можна було б оновити і selectedItem
також, легше виправлення – позбавитися дублювання. У цьому прикладі замість об’єкта selectedItem
(який утворює дублювання щодо об’єктів усередині items
) у стані зберігається selectedId
, а потім отримується selectedItem
, шляхом пошуку в масиві items
елемента за ідентифікатором:
import { useState } from 'react'; const initialItems = [ { title: 'пиріжок з горохом', id: 0 }, { title: 'горішки', id: 1 }, { title: 'зерновий батончик', id: 2 }, ]; export default function Menu() { const [items, setItems] = useState(initialItems); const [selectedId, setSelectedId] = useState(0); const selectedItem = items.find(item => item.id === selectedId ); function handleItemChange(id, e) { setItems(items.map(item => { if (item.id === id) { return { ...item, title: e.target.value, }; } else { return item; } })); } return ( <> <h2>Який вам перекус?</h2> <ul> {items.map((item, index) => ( <li key={item.id}> <input value={item.title} onChange={e => { handleItemChange(item.id, e) }} /> {' '} <button onClick={() => { setSelectedId(item.id); }}>Оберіть</button> </li> ))} </ul> <p>Ви обрали {selectedItem.title}.</p> </> ); }
Раніше стан дублювався отак:
items = [{ id: 0, title: 'пиріжок з горохом'}, ...]
selectedItem = {id: 0, title: 'пиріжок з горохом'}
Але після внесення змін він отакий:
items = [{ id: 0, title: 'пиріжок з горохом'}, ...]
selectedId = 0
Дублювання зникло, і залишився лише суттєвий стан!
Якщо тепер відредагувати обраний елемент, то повідомлення нижче оновиться негайно. Це пов’язано з тим, що setItems
запускає повторний рендер, а items.find(...)
знайде елемент з оновленою назвою. Не було потреби зберігати в стані обраний елемент, тому що суттєвим є лише обраний ідентифікатор. Решту можна обчислити під час рендеру.
Уникати глибокої вкладеності стану
Уявіть план подорожі, що складається з планет, континентів і країн. Може бути спокуса структурувати його стан за допомогою вкладених об’єктів і масивів, як у цьому прикладі:
export const initialTravelPlan = { id: 0, title: '(Root)', childPlaces: [{ id: 1, title: 'Земля', childPlaces: [{ id: 2, title: 'Африка', childPlaces: [{ id: 3, title: 'Ботсвана', childPlaces: [] }, { id: 4, title: 'Єгипет', childPlaces: [] }, { id: 5, title: 'Кенія', childPlaces: [] }, { id: 6, title: 'Мадагаскар', childPlaces: [] }, { id: 7, title: 'Марокко', childPlaces: [] }, { id: 8, title: 'Нігерія', childPlaces: [] }, { id: 9, title: 'Південно-Африканська Республіка', childPlaces: [] }] }, { id: 10, title: 'Америка', childPlaces: [{ id: 11, title: 'Аргентина', childPlaces: [] }, { id: 12, title: 'Бразилія', childPlaces: [] }, { id: 13, title: 'Барбадос', childPlaces: [] }, { id: 14, title: 'Канада', childPlaces: [] }, { id: 15, title: 'Ямайка', childPlaces: [] }, { id: 16, title: 'Мексика', childPlaces: [] }, { id: 17, title: 'Тринідад і Тобаго', childPlaces: [] }, { id: 18, title: 'Венесуела', childPlaces: [] }] }, { id: 19, title: 'Азія', childPlaces: [{ id: 20, title: 'Китай', childPlaces: [] }, { id: 21, title: 'Індія', childPlaces: [] }, { id: 22, title: 'Сингапур', childPlaces: [] }, { id: 23, title: 'Південна Корея', childPlaces: [] }, { id: 24, title: 'Тайланд', childPlaces: [] }, { id: 25, title: "В'єтнам", childPlaces: [] }] }, { id: 26, title: 'Європа', childPlaces: [{ id: 27, title: 'Хорватія', childPlaces: [], }, { id: 28, title: 'Франція', childPlaces: [], }, { id: 29, title: 'Німеччина', childPlaces: [], }, { id: 30, title: 'Італія', childPlaces: [], }, { id: 31, title: 'Португалія', childPlaces: [], }, { id: 32, title: 'Іспанія', childPlaces: [], }, { id: 33, title: 'Туреччина', childPlaces: [], }] }, { id: 34, title: 'Океанія', childPlaces: [{ id: 35, title: 'Австралія', childPlaces: [], }, { id: 36, title: 'Бора-Бора (Французька Полінезія)', childPlaces: [], }, { id: 37, title: 'Острів Пасхи (Чилі)', childPlaces: [], }, { id: 38, title: 'Фіджі', childPlaces: [], }, { id: 39, title: 'Гаваї (США)', childPlaces: [], }, { id: 40, title: 'Нова Зеландія', childPlaces: [], }, { id: 41, title: 'Вануату', childPlaces: [], }] }] }, { id: 42, title: 'Місяць', childPlaces: [{ id: 43, title: 'Рейта', childPlaces: [] }, { id: 44, title: 'Піколоміні', childPlaces: [] }, { id: 45, title: 'Тихо', childPlaces: [] }] }, { id: 46, title: 'Марс', childPlaces: [{ id: 47, title: 'Кукурудзяне', childPlaces: [] }, { id: 48, title: 'Зелений пагорб', childPlaces: [] }] }] };
Тепер, скажімо, ви хочете додати кнопку для видалення місця, яке вже відвідали. З чого почнете? Оновлення вкладеного стану включає створення копій об’єктів від частини, що змінилася, аж до самого кінця ланцюжка вкладеності. Видалення місця з глибокою вкладеністю включає копіювання всього його ланцюжка предків. Такий код може бути дуже громіздким.
Якщо стан має завелику вкладеність, щоб легко його оновити, розгляньте варіант його “сплющення”. Ось один з варіантів реструктуризації цих даних. Замість деревоподібної структури, де кожен place
має масив своїх дочірніх місць, кожне місце може зберігати масив ідентифікаторів своїх дочірніх місць. Крім цього, збережіть відображення з ідентифікатора кожного місця на відповідне місце.
Така реструктуризація даних може нагадати вам вигляд таблиці бази даних:
export const initialTravelPlan = { 0: { id: 0, title: '(Root)', childIds: [1, 42, 46], }, 1: { id: 1, title: 'Земля', childIds: [2, 10, 19, 26, 34] }, 2: { id: 2, title: 'Африка', childIds: [3, 4, 5, 6 , 7, 8, 9] }, 3: { id: 3, title: 'Ботсвана', childIds: [] }, 4: { id: 4, title: 'Єгипет', childIds: [] }, 5: { id: 5, title: 'Кенія', childIds: [] }, 6: { id: 6, title: 'Мадагаскар', childIds: [] }, 7: { id: 7, title: 'Марокко', childIds: [] }, 8: { id: 8, title: 'Нігерія', childIds: [] }, 9: { id: 9, title: 'Південно-Африканська Республіка', childIds: [] }, 10: { id: 10, title: 'Америка', childIds: [11, 12, 13, 14, 15, 16, 17, 18], }, 11: { id: 11, title: 'Аргентина', childIds: [] }, 12: { id: 12, title: 'Бразилія', childIds: [] }, 13: { id: 13, title: 'Барбадос', childIds: [] }, 14: { id: 14, title: 'Канада', childIds: [] }, 15: { id: 15, title: 'Ямайка', childIds: [] }, 16: { id: 16, title: 'Мексика', childIds: [] }, 17: { id: 17, title: 'Тринідад і Тобаго', childIds: [] }, 18: { id: 18, title: 'Венесуела', childIds: [] }, 19: { id: 19, title: 'Азія', childIds: [20, 21, 22, 23, 24, 25], }, 20: { id: 20, title: 'Китай', childIds: [] }, 21: { id: 21, title: 'Індія', childIds: [] }, 22: { id: 22, title: 'Сингапур', childIds: [] }, 23: { id: 23, title: 'Південна Корея', childIds: [] }, 24: { id: 24, title: 'Тайланд', childIds: [] }, 25: { id: 25, title: "В'єтнам", childIds: [] }, 26: { id: 26, title: 'Європа', childIds: [27, 28, 29, 30, 31, 32, 33], }, 27: { id: 27, title: 'Хорватія', childIds: [] }, 28: { id: 28, title: 'Франція', childIds: [] }, 29: { id: 29, title: 'Німеччина', childIds: [] }, 30: { id: 30, title: 'Італія', childIds: [] }, 31: { id: 31, title: 'Португалія', childIds: [] }, 32: { id: 32, title: 'Іспанія', childIds: [] }, 33: { id: 33, title: 'Туреччина', childIds: [] }, 34: { id: 34, title: 'Океанія', childIds: [35, 36, 37, 38, 39, 40, 41], }, 35: { id: 35, title: 'Австралія', childIds: [] }, 36: { id: 36, title: 'Бора-Бора (Французька Полінезія)', childIds: [] }, 37: { id: 37, title: 'Острів Пасхи (Чилі)', childIds: [] }, 38: { id: 38, title: 'Фіджі', childIds: [] }, 39: { id: 40, title: 'Гаваї (США)', childIds: [] }, 40: { id: 40, title: 'Нова Зеландія', childIds: [] }, 41: { id: 41, title: 'Вануату', childIds: [] }, 42: { id: 42, title: 'Місяць', childIds: [43, 44, 45] }, 43: { id: 43, title: 'Рейта', childIds: [] }, 44: { id: 44, title: 'Піколоміні', childIds: [] }, 45: { id: 45, title: 'Тихо', childIds: [] }, 46: { id: 46, title: 'Марс', childIds: [47, 48] }, 47: { id: 47, title: 'Кукурудзяне', childIds: [] }, 48: { id: 48, title: 'Зелений пагорб', childIds: [] } };
Тепер, коли стан “плаский” (або, як іще кажуть, “нормалізований”), оновлення вкладених елементів полегшилося.
Щоб вилучити місце тепер, необхідно оновити лише два рівні стану:
- Оновлена версія батьківсього місця повинна виключити вилучений ідентифікатор зі свого масиву
childIds
. - Оновлена версія кореневого “табличного” об’єкта повинна містити оновлену версію батьківського місця.
Ось приклад того, як до цього можна підійти:
import { useState } from 'react'; import { initialTravelPlan } from './places.js'; export default function TravelPlan() { const [plan, setPlan] = useState(initialTravelPlan); function handleComplete(parentId, childId) { const parent = plan[parentId]; // Створити нову версію батьківського місця, // що не містить цього дочірнього ідентифікатора. const nextParent = { ...parent, childIds: parent.childIds .filter(id => id !== childId) }; // Оновити кореневий об'єкт стану... setPlan({ ...plan, // ...щоб у ньому був оновлений батьківський елемент. [parentId]: nextParent }); } const root = plan[0]; const planetIds = root.childIds; return ( <> <h2>Місця, варті відвідування</h2> <ol> {planetIds.map(id => ( <PlaceTree key={id} id={id} parentId={0} placesById={plan} onComplete={handleComplete} /> ))} </ol> </> ); } function PlaceTree({ id, parentId, placesById, onComplete }) { const place = placesById[id]; const childIds = place.childIds; return ( <li> {place.title} <button onClick={() => { onComplete(parentId, id); }}> Завершити </button> {childIds.length > 0 && <ol> {childIds.map(childId => ( <PlaceTree key={childId} id={childId} parentId={id} placesById={placesById} onComplete={onComplete} /> ))} </ol> } </li> ); }
Можна робити стан як завгодно вкладеним, але його “сплющення” може розв’язати чимало проблем. Воно полегшує оновлення стану, а також допомагає пересвідчитись, що немає дублювання в різних частинах вкладеного об’єкта.
Deep Dive
В ідеалі краще також вилучати видалені елементи (та їхніх нащадків!) з “табличного” об’єкта, щоб покращити використання пам’яті. Ця версія це робить. Також вона використовує Immer, аби логіка була стислішою.
{ "dependencies": { "immer": "1.7.3", "react": "latest", "react-dom": "latest", "react-scripts": "latest", "use-immer": "0.5.1" }, "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test --env=jsdom", "eject": "react-scripts eject" }, "devDependencies": {} }
Іноді можна також знизити вкладеність стану, перенісши частину вкладеного стану до дочірніх компонентів. Це добре працює для ефемерного стану UI, який не потребує збереження, наприклад, чи наведений на елемент курсор.
Recap
- Якщо дві змінні стану завжди оновлюються разом, розгляньте варіант їх об’єднання докупи.
- Обирайте свої змінні стану ретельно, щоб уникати створення “неможливих” станів.
- Структуруйте свій стан так, щоб знижувати ймовірність помилок при його оновленні.
- Уникайте надлишкового та дубльованого стану, щоб не доводилося його синхронізувати.
- Не додавайте пропси у стан, якщо не хочете свідомо запобігти оновленням.
- У разі патернів UI штибу меню вибору, зберігайте у стані ідентифікатор або індекс, а не сам об’єкт.
- Якщо оновлення стану з глибокою вкладеністю – складне, спробуйте його сплющити.
Challenge 1 of 4: Виправлення компонента, що не оновлюється
Цей компонент Clock
отримує два пропси: color
і time
. Коли обрати в блоку вибору інший колір, компонент отримає від свого батьківського компонента інший проп color
. Проте чомусь виведений колір не оновлюється. Чому? Виправіть проблему.
import { useState } from 'react'; export default function Clock(props) { const [color, setColor] = useState(props.color); return ( <h1 style={{ color: color }}> {props.time} </h1> ); }