Пошук уроків, статей та іншого контенту
Навчитеся правильно задавати типи стану, посилань і редукторів у useState, useRef та useReducer.
useState, useRef і useReducer мають узагальнений тип у TypeScript. У простих випадках TypeScript визначає його автоматично, але для початкових значень null, порожніх масивів або складних об'єктів тип потрібно задати явно.
Правильна типізація допомагає:
не записувати в стан несумісні значення;
отримувати підказки редактора;
безпечно працювати з DOM-посиланнями;
гарантувати, що всі дії редуктора обробляються;
уникати помилок під час рефакторингу.
useStateЯкщо початкове значення достатньо інформативне, TypeScript виведе тип автоматично:
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
setCount(10); // Правильно
// setCount("10"); // Помилка: очікується number
return (
<button onClick={() => setCount((current) => current + 1)}>
Лічильник: {count}
</button>
);
}У цьому прикладі count має тип number, тому функція setCount приймає лише числа або функцію, яка повертає число.
Тип useState можна вказати явно:
const [name, setName] = useState<string>("");
const [isVisible, setIsVisible] = useState<boolean>(false);Для примітивних значень явний generic часто не потрібен, адже TypeScript і так правильно виведе тип. Він стає корисним, коли тип не можна визначити лише з початкового значення.
nullЯкщо стан спочатку дорівнює null, TypeScript може вивести занадто вузький тип:
const [user, setUser] = useState(null);У такому разі user матиме тип null, і записати в нього об'єкт не вдасться.
Потрібно описати всі можливі значення стану:
type User = {
id: number;
name: string;
};
const [user, setUser] = useState<User | null>(null);
setUser({
id: 1,
name: "Олена",
});
setUser(null);Тепер стан може містити або об'єкт User, або null.
Під час читання такого стану потрібно враховувати обидва варіанти:
return <p>{user ? user.name : "Користувача не вибрано"}</p>;Або скористатися optional chaining:
return <p>{user?.name ?? "Користувача не вибрано"}</p>;Порожній масив не містить достатньо інформації для виведення типу елементів:
const [items, setItems] = useState([]);У такому випадку TypeScript може вивести тип never[], що заборонить додавати елементи.
Потрібно вказати тип елемента:
type Todo = {
id: number;
title: string;
completed: boolean;
};
const [todos, setTodos] = useState<Todo[]>([]);Тепер TypeScript перевіряє кожен доданий елемент:
setTodos((currentTodos) => [
...currentTodos,
{
id: 1,
title: "Вивчити TypeScript",
completed: false,
},
]);Об'єкт із неправильним полем спричинить помилку:
// setTodos((currentTodos) => [
// ...currentTodos,
// { id: 2, text: "Невірне поле" },
// ]);Для об'єкта зручно створити окремий тип:
type FormState = {
email: string;
password: string;
rememberMe: boolean;
};
const [form, setForm] = useState<FormState>({
email: "",
password: "",
rememberMe: false,
});Під час оновлення потрібно зберігати інші властивості об'єкта:
setForm((currentForm) => ({
...currentForm,
email: "user@example.com",
}));Функціональна форма оновлення особливо важлива, коли нове значення залежить від попереднього стану:
setForm((currentForm) => ({
...currentForm,
rememberMe: !currentForm.rememberMe,
}));Стан може мати кілька взаємовиключних варіантів. Наприклад, результат запиту може бути успішним або містити помилку:
type RequestState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: string[] }
| { status: "error"; message: string };
const [request, setRequest] = useState<RequestState>({
status: "idle",
});Поле status допомагає TypeScript звузити тип:
if (request.status === "success") {
request.data.forEach((item) => {
console.log(item);
});
}
if (request.status === "error") {
console.error(request.message);
}useRefuseRef використовується для двох основних сценаріїв:
зберігання посилання на DOM-елемент;
зберігання змінного значення, яке не спричиняє повторний рендер.
Для посилання на елемент потрібно передати тип відповідного DOM-елемента:
import { useRef } from "react";
function SearchInput() {
const inputRef = useRef<HTMLInputElement | null>(null);
const focusInput = () => {
inputRef.current?.focus();
};
return (
<>
<input ref={inputRef} type="search" />
<button type="button" onClick={focusInput}>
Фокус
</button>
</>
);
}current спочатку дорівнює null, адже елемент ще не змонтований. Тому перед доступом до методу focus потрібно перевірити значення:
inputRef.current?.focus();Також можна використати звичайну перевірку:
if (inputRef.current !== null) {
inputRef.current.focus();
}Тип посилання має відповідати реальному елементу:
const buttonRef = useRef<HTMLButtonElement | null>(null);
const formRef = useRef<HTMLFormElement | null>(null);
const divRef = useRef<HTMLDivElement | null>(null);useRef може зберігати значення між рендерами:
const renderCountRef = useRef<number>(0);
renderCountRef.current += 1;Зміна current не запускає повторний рендер. Це відрізняє useRef від useState.
Для значення, яке спочатку може бути відсутнім, потрібно додати null до типу:
const previousValueRef = useRef<string | null>(null);
previousValueRef.current = "нове значення";Тип useRef залежить від того, що саме потрібно робити з current.
Для DOM-посилання зазвичай використовується тип елемента разом із null:
const inputRef = useRef<HTMLInputElement | null>(null);Для власного змінного значення також потрібно вказати тип:
const requestIdRef = useRef<number | null>(null);
requestIdRef.current = 42;Не варто використовувати useRef для даних, які мають відображатися в інтерфейсі. Якщо після зміни значення компонент повинен перемалюватися, потрібен useState.
useReduceruseReducer зручний, коли стан має складну структуру або багато способів оновлення.
Для його типізації потрібно описати:
тип стану;
тип усіх можливих дій;
функцію-редуктор, яка повертає новий стан.
type CounterState = {
value: number;
step: number;
};Кожна дія повинна мати поле type. Для дій із додатковими даними описуються відповідні поля:
type CounterAction =
| { type: "increment" }
| { type: "decrement" }
| { type: "setStep"; step: number };Значення type є строковим літералом, а не довільним рядком. Завдяки цьому TypeScript може точно визначити, яку дію обробляє редуктор.
function counterReducer(
state: CounterState,
action: CounterAction,
): CounterState {
switch (action.type) {
case "increment":
return {
...state,
value: state.value + state.step,
};
case "decrement":
return {
...state,
value: state.value - state.step,
};
case "setStep":
return {
...state,
step: action.step,
};
default:
return state;
}
}У гілці case "setStep" TypeScript знає, що action.step існує. В інших гілках цього поля немає, тому випадкове використання action.step буде помилкою.
useReducerimport { useReducer } from "react";
const initialCounterState: CounterState = {
value: 0,
step: 1,
};
function Counter() {
const [state, dispatch] = useReducer(
counterReducer,
initialCounterState,
);
return (
<section>
<p>Значення: {state.value}</p>
<p>Крок: {state.step}</p>
<button
type="button"
onClick={() => dispatch({ type: "decrement" })}
>
−
</button>
<button
type="button"
onClick={() => dispatch({ type: "increment" })}
>
+
</button>
<button
type="button"
onClick={() => dispatch({ type: "setStep", step: 5 })}
>
Крок 5
</button>
</section>
);
}TypeScript перевіряє аргумент dispatch:
dispatch({ type: "increment" }); // Правильно
dispatch({ type: "setStep", step: 3 }); // Правильно
// dispatch({ type: "unknown" }); // Помилка
// dispatch({ type: "setStep" }); // Помилка: відсутнє поле stepДля додаткової перевірки можна створити функцію, яка приймає лише never:
function assertNever(value: never): never {
throw new Error(`Непідтримувана дія: ${JSON.stringify(value)}`);
}Після цього використати її в default:
function counterReducer(
state: CounterState,
action: CounterAction,
): CounterState {
switch (action.type) {
case "increment":
return {
...state,
value: state.value + state.step,
};
case "decrement":
return {
...state,
value: state.value - state.step,
};
case "setStep":
return {
...state,
step: action.step,
};
default:
return assertNever(action);
}
}Якщо до CounterAction додати новий варіант і не обробити його в switch, TypeScript повідомить про помилку в assertNever(action). Це допомагає не забути оновити редуктор після зміни типу дій.
У наступному компоненті одночасно використовуються всі три хуки:
useState з об'єктом;
useRef із посиланням на input;
useReducer із типізованим станом і діями.
import {
type FormEvent,
useReducer,
useRef,
useState,
} from "react";
type CounterState = {
value: number;
};
type CounterAction =
| { type: "increment" }
| { type: "decrement" }
| { type: "reset" };
function counterReducer(
state: CounterState,
action: CounterAction,
): CounterState {
switch (action.type) {
case "increment":
return { value: state.value + 1 };
case "decrement":
return { value: state.value - 1 };
case "reset":
return { value: 0 };
default:
return state;
}
}
type Profile = {
name: string;
email: string;
};
export default function ProfileForm() {
const [profile, setProfile] = useState<Profile>({
name: "",
email: "",
});
const [counter, dispatch] = useReducer(counterReducer, {
value: 0,
});
const nameInputRef = useRef<HTMLInputElement | null>(null);
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
console.log(profile);
};
const focusNameInput = () => {
nameInputRef.current?.focus();
};
return (
<main>
<form onSubmit={handleSubmit}>
<label>
Ім'я
<input
ref={nameInputRef}
value={profile.name}
onChange={(event) => {
setProfile((currentProfile) => ({
...currentProfile,
name: event.target.value,
}));
}}
/>
</label>
<label>
Email
<input
type="email"
value={profile.email}
onChange={(event) => {
setProfile((currentProfile) => ({
...currentProfile,
email: event.target.value,
}));
}}
/>
</label>
<button type="submit">Зберегти</button>
<button type="button" onClick={focusNameInput}>
Перейти до поля імені
</button>
</form>
<section>
<p>Лічильник: {counter.value}</p>
<button
type="button"
onClick={() => dispatch({ type: "decrement" })}
>
−
</button>
<button
type="button"
onClick={() => dispatch({ type: "increment" })}
>
+
</button>
<button
type="button"
onClick={() => dispatch({ type: "reset" })}
>
Скинути
</button>
</section>
</main>
);
}nullНеправильно:
const [selectedUser, setSelectedUser] = useState(null);У такому випадку TypeScript не дозволить встановити об'єкт користувача.
Правильно:
type User = {
id: number;
name: string;
};
const [selectedUser, setSelectedUser] = useState<User | null>(null);Неправильно:
const [names, setNames] = useState([]);Правильно:
const [names, setNames] = useState<string[]>([]);useRef без перевірки nullНеправильно:
const inputRef = useRef<HTMLInputElement | null>(null);
// Помилка: current може дорівнювати null
inputRef.current.focus();Правильно:
inputRef.current?.focus();useRef замість useStateНеправильно зберігати через useRef значення, яке має показуватися в інтерфейсі:
const countRef = useRef(0);
const increment = () => {
countRef.current += 1;
};Зміна countRef.current не перемалює компонент. Для такого значення потрібен useState:
const [count, setCount] = useState(0);
const increment = () => {
setCount((currentCount) => currentCount + 1);
};Невдалий варіант:
type Action = {
type: string;
};Такий тип дозволяє передати будь-який рядок і не захищає від невідомих дій.
Краще використовувати union строкових літералів:
type Action =
| { type: "increment" }
| { type: "decrement" };Неправильно:
function reducer(state: CounterState): CounterState {
state.value += 1;
return state;
}Редуктор повинен повертати новий об'єкт стану:
function reducer(state: CounterState): CounterState {
return {
...state,
value: state.value + 1,
};
}useState часто виводить тип автоматично, але для null, порожніх масивів і складних станів тип потрібно вказувати явно.
Для стану, який може бути відсутнім, використовуйте об'єднання на кшталт User | null.
Для масивів задавайте тип елементів: useState<Item[]>([]).
useRef для DOM-елемента типізується відповідним типом елемента, наприклад HTMLInputElement | null.
Зміна ref.current не спричиняє повторний рендер.
Для useReducer окремо описуйте тип стану та union усіх можливих дій.
Дискриміновані дії з полем type дають TypeScript змогу перевіряти редуктор і виклики dispatch.
Редуктор не повинен мутувати наявний стан, а має повертати новий об'єкт.