Контрольовані та неконтрольовані компоненти в React
Контрольований компонент зберігає значення форми в стані React і синхронізує його при кожному введенні. Неконтрольований компонент дозволяє DOM тримати значення, а ти зчитуєш його через ref тоді, коли потрібно.
Теорія
TL;DR
- Контрольований: стан React є єдиним джерелом правди,
onChangeспрацьовує при кожному натисканні і запускає ре-рендер - Неконтрольований: DOM тримає значення, ти зчитуєш його через
refза потреби - Аналогія: контрольований - це живий чат, який синхронізує кожен символ; неконтрольований - паперова форма, яку заповнюєш і здаєш в кінці
- Використовуй контрольований для валідації в реальному часі або умовного рендерингу
- Використовуй неконтрольований для простих форм, file-інпутів або інтеграції зі сторонніми бібліотеками
Швидкий приклад
// КОНТРОЛЬОВАНИЙ: React тримає значення
function Controlled() {
const [name, setName] = useState("");
return (
<input value={name} onChange={(e) => setName(e.target.value)} />
// name завжди синхронізовано з тим, що ввів користувач
);
}
// НЕКОНТРОЛЬОВАНИЙ: DOM тримає значення
function Uncontrolled() {
const inputRef = useRef();
return (
<>
<input ref={inputRef} />
<button onClick={() => console.log(inputRef.current.value)}>
Відправити
</button>
{/* значення зчитується тільки при кліку */}
</>
);
}У контрольованому value і onChange завжди йдуть разом. У неконтрольованому ref вказує безпосередньо на DOM-вузол.
Ключова різниця
У контрольованому компоненті стан React є єдиним джерелом правди. Кожне натискання клавіші запускає onChange, оновлює стан і викликає ре-рендер з новим значенням пропу value. У неконтрольованому браузер оновлює DOM напряму, нічого не повідомляючи React. Коли ти читаєш inputRef.current.value, ти повністю обходиш React і звертаєшся до DOM-вузла. Тобто контрольований інпут завжди синхронізований зі станом React, а неконтрольований може тримати значення, про яке React нічого не знає.
Коли що використовувати
Контрольований:
- Валідація в реальному часі - кнопка submit неактивна поки email некоректний
- Умовний рендеринг залежно від введення - підказки автодоповнення при наборі
- Форми з попередньо заповненими даними з API або пропів
- Будь-яка ситуація, де React має реагувати на кожну зміну
Неконтрольований:
- Прості форми, де значення потрібне тільки при відправці
- File-інпути (
<input type="file">) - браузери блокують програмне встановленняvalueз міркувань безпеки - Інтеграція зі сторонніми DOM-бібліотеками, які самі керують своїм станом
- Форми з великою кількістю полів, де ре-рендер на кожне натискання викликає помітні затримки
Таблиця порівняння
| Аспект | Контрольований | Неконтрольований |
|---|---|---|
| Де зберігається значення | Стан React | DOM-елемент |
| Коли синхронізується | При кожному натисканні | За запитом (через ref) |
| Ре-рендер | Так, при кожній зміні | Ні, якщо не запустиш вручну |
| Валідація | Доступна в реальному часі | Тільки при відправці |
| Проп для початкового значення | value | defaultValue |
| Коли використовувати | Валідація, умовний UI, попередньо заповнені поля | Прості форми, file-інпути, сторонні бібліотеки |
Як це працює зсередини
Коли ти вводиш текст у контрольований інпут, браузер генерує подію onChange. Обробник React викликає сетер useState, планує ре-рендер, порівнює старий і новий virtual DOM і оновлює реальний DOM з новим пропом value. Все це відбувається в рамках одного циклу події.
Для неконтрольованих інпутів браузер оновлює DOM напряму. Virtual DOM React цю зміну взагалі не бачить. Читання inputRef.current.value йде прямо до DOM-вузла, повністю минаючи React. Ні ре-рендеру, ні оновлення стану, ні порівняння.
Типові помилки
1. Використання value без onChange
// НЕПРАВИЛЬНО: інпут стає read-only
<input value={name} />
// ПРАВИЛЬНО: завжди йдуть разом
<input value={name} onChange={(e) => setName(e.target.value)} />React встановлює значення пропу, але не має механізму для його оновлення. Користувач не може нічого ввести. React також виводить попередження в консоль.
2. Використання value замість defaultValue у неконтрольованих компонентах
// НЕПРАВИЛЬНО: це робить інпут контрольованим без обробника onChange
<input ref={inputRef} value="initial" />
// ПРАВИЛЬНО: defaultValue встановлює початкове значення, не беручи контроль
<input ref={inputRef} defaultValue="initial" />3. Зчитування ref до монтування компонента
// НЕПРАВИЛЬНО: inputRef.current є null під час першого рендеру
function Form() {
const inputRef = useRef();
const value = inputRef.current.value; // TypeError: null
return <input ref={inputRef} />;
}
// ПРАВИЛЬНО: читай ref в обробниках подій або ефектах
function Form() {
const inputRef = useRef();
const handleClick = () => {
console.log(inputRef.current.value); // тут безпечно
};
return <input ref={inputRef} />;
}4. Ініціалізація стану контрольованого компонента як undefined
// НЕПРАВИЛЬНО: undefined означає відсутність пропу value, React вважає інпут неконтрольованим
const [name, setName] = useState();
<input value={name} onChange={(e) => setName(e.target.value)} />
// ПРАВИЛЬНО: ініціалізуй порожнім рядком
const [name, setName] = useState("");
<input value={name} onChange={(e) => setName(e.target.value)} />React виводить попередження "A component is changing an uncontrolled input to be controlled." Це трапляється тому, що undefined дорівнює відсутності пропу value на першому рендері.
5. Запуск важких операцій при кожному натисканні
// ПОВІЛЬНО: expensiveSearch запускається при кожному символі
const handleChange = (e) => {
setSearch(e.target.value);
expensiveSearch(e.target.value);
};
// КРАЩЕ: debounce для важкої частини, стан оновлюється одразу
const handleSearch = useCallback(
debounce((value) => expensiveSearch(value), 300),
[]
);
const handleChange = (e) => {
setSearch(e.target.value);
handleSearch(e.target.value);
};Де це зустрічається
- React Hook Form за замовчуванням використовує неконтрольовані інпути - зберігає значення в ref замість стану, щоб уникнути ре-рендерингу при кожному натисканні
- Material-UI і Chakra UI передають
valueіonChangeдо всіх компонентів форм - контрольований патерн за замовчуванням <input type="file">завжди неконтрольований, бо браузери блокують програмне встановленняvalueз міркувань безпеки- Redux-форми зберігають значення полів у Redux-стані - це контрольований патерн на глобальному рівні
- Server Actions у Next.js добре поєднуються з неконтрольованими формами через
FormDataAPI, без будь-якого стану
Питання на співбесіді
Q: Чому React попереджає "You provided a value prop without an onChange handler"?
A: Бо інпут стає read-only. React встановлює значення, але не має механізму для його оновлення, тому користувач нічого не може ввести. React виявляє цю невідповідність завчасно, щоб ти не витрачав час на дебагінг замороженого поля.
Q: Чи може компонент перемкнутись з неконтрольованого на контрольований під час роботи?
A: Ні. React кидає помилку при переключенні в будь-який бік. Потрібно обрати один патерн і дотримуватись його протягом усього часу існування компонента.
Q: У чому реальна різниця у продуктивності?
A: Контрольовані компоненти ре-рендеряться при кожному натисканні. З великими формами або важкою логікою рендеру це стає помітним. Неконтрольовані повністю пропускають цикл рендерингу React, але ти втрачаєш валідацію в реальному часі. React Hook Form вирішує це, використовуючи неконтрольовані інпути внутрішньо з контрольованим API назовні.
Q: Форма з 50 полями - який підхід обрати?
A: Контрольований з оптимізацією. Розбий форму на менші підкомпоненти, мемоїзуй обробники через useCallback і додай debounce для важких операцій. Або використовуй React Hook Form, який вирішує це внутрішньо.
Q: (Senior) Чому React Hook Form за замовчуванням використовує неконтрольовані компоненти і коли це варто змінити?
A: React Hook Form зберігає значення в ref, а не в стані, щоб уникнути ре-рендерингу форми при кожному введенні. Ти переходиш до контрольованого режиму (через Controller або useController), коли інтегруєш контрольовані UI-бібліотеки типу Material-UI або коли потрібен умовний рендеринг на основі значення інпуту в реальному часі.
Приклади
Контрольована форма з валідацією email
function SignupForm() {
const [email, setEmail] = useState("");
const [error, setError] = useState("");
const handleChange = (e) => {
const value = e.target.value;
setEmail(value);
if (value && !value.includes("@")) {
setError("Некоректний email");
} else {
setError("");
}
};
return (
<div>
<input value={email} onChange={handleChange} placeholder="Email" />
{error && <span style={{ color: "red" }}>{error}</span>}
{/* повідомлення з'являється і зникає при наборі */}
</div>
);
}Повідомлення про помилку реагує в реальному часі, бо контрольований інпут ре-рендериться при кожному натисканні. З неконтрольованим ти б побачив некоректний email тільки після кліку на submit.
Неконтрольована форма для простої відправки
function ContactForm() {
const nameRef = useRef();
const emailRef = useRef();
const handleSubmit = (e) => {
e.preventDefault();
const data = {
name: nameRef.current.value,
email: emailRef.current.value,
};
console.log(data); // зчитуємо обидва значення одразу при відправці
};
return (
<form onSubmit={handleSubmit}>
<input ref={nameRef} defaultValue="" placeholder="Ім'я" />
<input ref={emailRef} defaultValue="" placeholder="Email" />
<button type="submit">Відправити</button>
</form>
);
}При наборі ре-рендерів немає. React включається тільки при відправці. Добре підходить для простих форм без потреби в реальному зворотному зв'язку.
Чому не варто змішувати обидва підходи
// НЕ РОБИ ТАК
function BadMix() {
const [value, setValue] = useState("");
const inputRef = useRef();
return (
<>
<input
value={value}
onChange={(e) => setValue(e.target.value)}
ref={inputRef}
/>
<button onClick={() => console.log(inputRef.current.value)}>
Вивести значення
</button>
{/* ref читає DOM-вузол коректно, але стан вже тримає те саме */}
{/* два способи отримати одні дані - обери один */}
</>
);
}Ref зчитує DOM-вузол правильно. Але стан вже тримає те саме значення. Використовувати обидва - це зайвий когнітивний overhead і пряме питання: "Якому з двох джерел вірити?". Такий патерн нерідко з'являється в код-рев'ю, коли розробники приходять з jQuery і намагаються поєднати обидва світи. Обери один підхід і дотримуйся його.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.