React · TypeScript · Tailwind
Фронтенд-минимум для backend-разработчика: хуки и причины перерисовок, где какое состояние держать, система типов TypeScript и её граница с рантайм-валидацией, утилитарные классы Tailwind.
Три технологии, которые почти всегда идут вместе. Разбор сосредоточен на том, что чаще всего ломается: зависимости эффектов, лишние перерисовки, граница между типами и рантаймом, разрастание классов в разметке.
useState ≈ ref, useEffect ≈ watch + onMounted, useMemo ≈ computed. Главное отличие: во Vue реактивность отслеживается автоматически, в React зависимости перечисляются руками — отсюда почти все баги с устаревшими данными.JSX не является частью JavaScript. Сборщик (Vite, esbuild, tsc) превращает его в обычные вызовы функции. Понимать это важно: отсюда следуют все правила — почему атрибут называется className, почему нельзя вернуть два элемента подряд и почему условие пишется выражением, а не оператором.
// Что вы пишете
const el = <button className="btn" onClick={save}>Сохранить</button>;
// Во что это превращается (новый JSX-трансформ, React 17+)
import { jsx as _jsx } from 'react/jsx-runtime';
const el = _jsx('button', { className: 'btn', onClick: save, children: 'Сохранить' });
// Результат — обычный объект, описание того, что нужно нарисовать:
// { type: 'button', props: { className: 'btn', onClick: f, children: 'Сохранить' } }
| В HTML | В JSX | Почему |
|---|---|---|
class | className | class — зарезервированное слово JavaScript |
for | htmlFor | for — оператор цикла |
onclick="..." | onClick={fn} | Передаётся функция, а не строка кода |
style="color:red" | style={{ color: 'red' }} | Объект свойств, имена в camelCase |
<!-- … --> | {/* … */} | Комментарий — это выражение JavaScript |
В {} подставляется выражение — то, что имеет значение. Оператор if или цикл for значения не имеют, поэтому внутри JSX используют тернарный оператор, && и map.
function OrderStatus({ order, user }) {
return (
<div>
{/* тернарный — когда нужны обе ветки */}
{order.paid ? <Badge kind="ok">Оплачен</Badge> : <Badge>Ожидает оплаты</Badge>}
{/* && — когда ветка одна */}
{user.isAdmin && <AdminPanel orderId={order.id} />}
{/* список — через map, не через for */}
<ul>
{order.items.map(item => <li key={item.id}>{item.title}</li>)}
</ul>
</div>
);
}
{items.length && <List />} при пустом массиве выведет на экран 0: 0 && x возвращает 0, а React печатает числа. React не рисует только false, null и undefined. Пишите items.length > 0 && ….{user}, где user — объект, роняет рендер с «Objects are not valid as a React child». Нужно выбрать поле: {user.name}.Функция возвращает одно значение, поэтому и компонент возвращает один элемент. Когда лишняя обёртка <div> ломает вёрстку (например, внутри grid или таблицы), используют фрагмент.
// Ошибка сборки: два корневых элемента
return (
<td>{row.name}</td>
<td>{row.total}</td>
);
// Фрагмент — не создаёт узла в DOM
return (
<>
<td>{row.name}</td>
<td>{row.total}</td>
</>
);
// Если нужен key — только полная форма
{rows.map(row => (
<React.Fragment key={row.id}>
<dt>{row.name}</dt>
<dd>{row.total}</dd>
</React.Fragment>
))}
Когда состояние меняется, React вызывает компонент заново, получает новое описание и сравнивает его с предыдущим. В DOM уходят только отличия — это и называют согласованием (reconciliation).
Сравнение идёт по позиции в дереве и по типу элемента. Именно поэтому key в списках так важен — он подсказывает React, какой элемент какому соответствует, когда порядок меняется. Подробнее — в разделе Списки, ключи и формы.
<Form /> в одной ветке условия обёрнут в <div>, а в другой нет — React видит разный тип на этой позиции, уничтожает старое поддерево и создаёт новое. Всё внутреннее состояние формы теряется.Хук — функция, которая даёт компоненту доступ к возможностям React: памяти между рендерами, побочным эффектам, контексту. Все они начинаются с use.
React не знает имён ваших переменных. Он хранит состояние компонента списком и на каждом рендере выдаёт значения по порядку вызовов. Отсюда и единственное жёсткое правило: порядок вызовов хуков должен быть одинаковым при каждом рендере.
| Правило | Что произойдёт при нарушении |
|---|---|
| Только на верхнем уровне функции | Хук в if, цикле или после раннего return сдвигает нумерацию — React выбрасывает ошибку о изменившемся порядке |
| Только из компонента или другого хука | Вызов из обычной функции или обработчика события: React не поймёт, какому компоненту принадлежит состояние |
Имя начинается с use | Формально не обязательно, но линтер перестанет проверять зависимости и правила |
if» здесь не работает. Компонент вызывается заново десятки раз, а состояние живёт снаружи него — в React. Хуки и есть способ дотянуться до этой внешней памяти, и дотягиваться нужно всегда в одном порядке.Значение состояния в конкретном рендере — это снимок. Вызов функции-установщика не меняет уже существующую переменную, а просит React отрисовать компонент заново с новым значением.
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// count здесь всё ещё 0 — это снимок текущего рендера.
// Результат: 1, а не 3.
}
function handleClickRight() {
setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);
// Функциональная форма: каждая получает актуальное значение. Результат: 3.
}
Несколько вызовов подряд React объединяет в одну перерисовку — это называют пакетированием (batching). Начиная с React 18 оно работает и внутри промисов, и в таймерах, а не только в обработчиках событий.
// Тяжёлое начальное значение: так функция выполняется на каждом рендере
const [rows, setRows] = useState(parseHugeCsv(raw));
// Ленивая инициализация: функция выполнится ровно один раз
const [rows, setRows] = useState(() => parseHugeCsv(raw));
setCount(5); console.log(count); выведет старое значение. Новое появится только в следующем рендере — это не задержка, а то же самое свойство снимка.items.push(x); setItems(items) не перерисует компонент: ссылка на массив прежняя. Нужен новый массив — setItems([...items, x]).Самый частый источник запутанного кода у начинающих — эффекты там, где они не требуются. Эффект нужен для синхронизации с внешним миром: сеть, подписки, таймеры, прямая работа с DOM. Всё остальное считается во время рендера.
| Задача | Через эффект | Как правильно |
|---|---|---|
| Значение выводится из props или состояния | Лишний рендер и риск рассинхрона | Посчитать прямо при рендере |
| Расчёт дорогой | — | useMemo |
| Реакция на действие пользователя | Логика размазана между кликом и эффектом | Прямо в обработчике события |
| Сбросить состояние при смене сущности | useEffect со сравнением id | Передать key компоненту — React пересоздаст его |
// Лишний эффект: два рендера и состояние, которое может разъехаться
const [fullName, setFullName] = useState('');
useEffect(() => { setFullName(first + ' ' + last); }, [first, last]);
// Обычное вычисление — всегда согласовано
const fullName = first + ' ' + last;
// Сброс формы эффектом при смене пользователя
useEffect(() => { setDraft(''); }, [userId]);
// Новый key — новый экземпляр компонента со свежим состоянием
<ProfileForm key={userId} userId={userId} />
Эффект перезапускается при изменении зависимостей. Возвращаемая функция — очистка: отписка от сокета, clearInterval, AbortController для fetch.
useEffect(() => {
const controller = new AbortController();
fetch(`/api/chats/${chatId}`, { signal: controller.signal })
.then(r => r.json())
.then(setMessages)
.catch(e => { if (e.name !== 'AbortError') report(e); });
return () => controller.abort(); // очистка при смене chatId или размонтировании
}, [chatId]);
| Массив зависимостей | Когда запускается |
|---|---|
[] | Только при монтировании |
[a, b] | При монтировании и при изменении a или b |
| Не указан | После каждой перерисовки — почти всегда ошибка |
state того рендера, в котором был создан. Если зависимость не указана, внутри останется старое значение — и код будет работать «через раз». Лечится честными зависимостями или функциональной формой: setCount(c => c + 1) вместо setCount(count + 1).chatId придёт позже нового и перетрёт данные. Отмена через AbortController в очистке решает это.- Изменился его
state - Изменились
props - Изменилось значение контекста, который он читает
- Перерисовался родитель — даже если props те же
| Инструмент | Что делает |
|---|---|
React.memo | Пропускает перерисовку, если props не изменились по поверхностному сравнению |
useMemo | Кеширует результат вычисления |
useCallback | Сохраняет ссылку на функцию между рендерами |
| Разделение контекстов | Часто меняющееся значение не тянет за собой всех потребителей |
| Ключи в списках | Стабильный key позволяет переиспользовать узлы, а не пересоздавать |
React.memo часто не срабатывает. Сравнение поверхностное, а объекты и функции создаются заново на каждом рендере родителя. Передали style={{ margin: 8 }} или инлайновый обработчик — и memo бесполезен: ссылка каждый раз новая. Поэтому memo работает в паре с useCallback и useMemo, а не сам по себе.useMemo всё подряд.Главное заблуждение при оптимизации. «Компонент перерисовался» означает лишь, что React вызвал функцию компонента и сравнил результат с прошлым. Если разметка не изменилась, в DOM не уйдёт ни одной правки — а именно работа с DOM и стоит дорого.
Поэтому сотня «лишних» вызовов простых компонентов обычно не влияет ни на что. Проблемой перерисовки становятся в трёх случаях:
| Случай | Почему больно |
|---|---|
| Длинные списки | Тысяча строк пересобирается при каждом нажатии клавиши в фильтре |
| Тяжёлый расчёт при рендере | Сортировка или разбор данных повторяется без надобности |
| Частые события | Движение мыши, скролл, ввод текста — десятки рендеров в секунду |
memo. Мемоизация сама по себе не бесплатна: сравнение props и хранение кэша тоже стоят времени и памяти.| Инструмент | Что показывает |
|---|---|
| React DevTools → вкладка Profiler | Запись сеанса: какие компоненты рендерились, сколько заняли и почему |
| Настройка «Highlight updates» | Подсвечивает перерисовываемые области прямо на странице — сразу видно лишнее |
| Вкладка Performance в браузере | Разделяет время на скрипт, пересчёт стилей и отрисовку — видно, где именно узкое место |
Типичная находка: тормозит не тот компонент, который перерисовывается часто, а тот, что делает тяжёлую работу в теле.
// Сортировка на каждом рендере, даже когда менялся только фильтр
function Table({ rows, query }) {
const sorted = [...rows].sort(byDate); // 10 000 элементов
const shown = sorted.filter(r => r.name.includes(query));
return <List items={shown} />;
}
// Дорогое закэшировано по своим зависимостям, дешёвое считается всегда
function Table({ rows, query }) {
const sorted = useMemo(() => [...rows].sort(byDate), [rows]);
const shown = sorted.filter(r => r.name.includes(query));
return <List items={shown} />;
}
useCallback, а каждое значение в useMemo — распространённая ошибка. Код становится труднее читать, зависимостей больше, а выигрыша нет: для дешёвых значений сравнение стоит дороже пересчёта.memo, useMemo и useCallback в большинстве случаев становятся не нужны — но проверять выигрыш всё равно профайлером.| Вид состояния | Чем держать | Примеры |
|---|---|---|
| Локальное | useState, useReducer | Открыт ли дропдаун, значение поля ввода |
| Серверное | TanStack Query, RTK Query | Данные с API: кеш, ретраи, инвалидация |
| Глобальное клиентское | Zustand, Redux Toolkit, Context | Текущий пользователь, тема, выбранный чат |
Когда двум компонентам нужно одно значение, оно переносится в ближайшего общего родителя, а вниз уходят само значение и функция его изменения. Это базовый приём, из которого вырастают все остальные.
// Фильтр и список должны видеть одну строку поиска
function OrdersPage() {
const [query, setQuery] = useState(''); // состояние поднято сюда
return (
<>
<SearchBox value={query} onChange={setQuery} />
<OrderList query={query} />
</>
);
}
Если передавать значение приходится через четыре-пять этажей, это сигнал: либо нужен контекст, либо промежуточные компоненты стоит перестроить так, чтобы содержимое передавалось через children.
// Передача через children: Layout не знает ничего о данных,
// поэтому и props через него тянуть не нужно
<Layout>
<OrderList query={query} />
</Layout>
Часть состояния правильнее держать не в памяти, а в URL. Признак простой: нужно ли, чтобы это пережило перезагрузку страницы и чтобы ссылкой можно было поделиться.
| Хранить в URL | Хранить в памяти |
|---|---|
| Фильтры и поиск в таблице | Открыт ли выпадающий список |
| Номер страницы и сортировка | Текст, который ещё печатают |
| Активная вкладка раздела | Позиция прокрутки |
| Идентификатор открытой карточки | Наведение мыши |
// react-router: параметры запроса ведут себя как состояние
const [params, setParams] = useSearchParams();
const status = params.get('status') ?? 'all';
setParams(prev => {
prev.set('status', 'paid');
return prev;
});
// Теперь кнопка «назад» работает ожидаемо, а ссылку можно отправить коллеге
Браться за Redux Toolkit или Zustand стоит, когда клиентское состояние действительно общее и меняется из разных мест: корзина, подключение к сокету, права текущего пользователя.
| Context | Zustand | Redux Toolkit | |
|---|---|---|---|
| Объём кода | Минимальный | Небольшой | Заметный |
| Подписка на часть состояния | Нет, обновляются все | Да, по селектору | Да, по селектору |
| Инструменты отладки | Нет | Есть | Полная история действий |
| Где уместен | Тема, локаль, пользователь | Среднее приложение | Большая команда, сложные правила |
Кастомный хук — обычная функция, которая вызывает другие хуки. Нужен, чтобы вынести повторяющуюся логику из компонентов.
// Типизированные обёртки над Redux — чтобы не писать дженерики в каждом компоненте
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
// Хук с жизненным циклом: запускает поллинг и отменяет при размонтировании
export function useNotifications(enabled: boolean) {
const dispatch = useAppDispatch();
useEffect(() => {
if (!enabled) return;
const controller = new AbortController();
poll({ signal: controller.signal, dispatch });
return () => controller.abort();
}, [enabled, dispatch]);
}
use — по нему работает линтер.Хук оправдан, когда повторяется логика с состоянием или эффектами. Чистую функцию без хуков внутри выносить в хук не нужно — это просто функция.
| В хук | В обычную функцию |
|---|---|
| Подписка и отписка от события | Форматирование даты или суммы |
| Загрузка данных с отменой | Сортировка и фильтрация массива |
Работа с localStorage плюс состояние | Валидация строки |
| Таймеры и интервалы с очисткой | Подсчёт итогов по списку |
// Типовой хук: значение в localStorage, синхронизированное с состоянием
function useStored<T>(key: string, initial: T) {
const [value, setValue] = useState<T>(() => {
try {
const raw = localStorage.getItem(key);
return raw ? JSON.parse(raw) as T : initial;
} catch {
return initial; // приватный режим или испорченное значение
}
});
useEffect(() => {
try { localStorage.setItem(key, JSON.stringify(value)); } catch {}
}, [key, value]);
return [value, setValue] as const; // as const — чтобы вышел кортеж, а не массив
}
Небольшая деталь, заметно влияющая на удобство: что именно хук возвращает.
// Кортеж — когда значений два и имена даёт вызывающий
const [isOpen, setOpen] = useToggle(false);
const [isDark, setDark] = useToggle(true); // переименовать легко
// Объект — когда значений много и часть не нужна
const { data, isPending, refetch } = useOrders(userId);
// порядок не важен, лишнее можно не доставать
useState возвращает пару, а библиотеки запросов — объект с десятком полей.При обновлении списка React сопоставляет старые и новые элементы. Без key сопоставление идёт по порядковому номеру, и если элементы переставили, вставили или удалили, React решит, что изменилось содержимое каждой позиции, а не порядок.
// Индекс как key — ломается при вставке, удалении и сортировке
{users.map((u, i) => <UserRow key={i} user={u} />)}
// Стабильный идентификатор из данных
{users.map(u => <UserRow key={u.id} user={u} />)}
key={i} удалите первую строку — введённый текст «съедет» на соседнюю строку. Состояние осталось привязано к позиции, а не к записи.key не попадает в props: прочитать его внутри компонента нельзя, нужно передать отдельным атрибутом.У поля ввода есть собственное состояние в DOM. Вопрос в том, кто считается источником истины — React или сам браузер.
| Управляемое | Неуправляемое | |
|---|---|---|
| Источник истины | Состояние React | Сам DOM-узел |
| Как пишется | value + onChange | defaultValue + ref |
| Перерисовка | На каждое нажатие клавиши | Не происходит |
| Когда удобно | Валидация на лету, зависимые поля, форматирование ввода | Большие формы, файлы, интеграция со сторонним кодом |
// Управляемое: React знает значение в любой момент
const [email, setEmail] = useState('');
<input value={email} onChange={e => setEmail(e.target.value)} />
// Неуправляемое: значение читаем в момент отправки
const emailRef = useRef(null);
<input defaultValue="" ref={emailRef} />
// ... emailRef.current.value
defaultValue.useState() без аргумента делает поле неуправляемым, а первый ввод переключает его в управляемое. React предупреждает об этом в консоли. Начинайте с пустой строки.Браузер по умолчанию перезагружает страницу при отправке. Это нужно отменить и обязательно блокировать повторную отправку, пока запрос в пути.
function LoginForm() {
const [form, setForm] = useState({ email: '', password: '' });
const [sending, setSending] = useState(false);
const [error, setError] = useState(null);
// одна функция на все поля — по атрибуту name
const change = e =>
setForm(f => ({ ...f, [e.target.name]: e.target.value }));
async function submit(e) {
e.preventDefault(); // без этого страница перезагрузится
if (sending) return; // защита от двойного клика
setSending(true); setError(null);
try {
await login(form);
} catch (err) {
setError(err.message);
} finally {
setSending(false); // finally — иначе форма зависнет после ошибки
}
}
return (
<form onSubmit={submit}>
<input name="email" value={form.email} onChange={change} />
<input name="password" type="password" value={form.password} onChange={change} />
{error && <p role="alert">{error}</p>}
<button disabled={sending}>{sending ? 'Входим…' : 'Войти'}</button>
</form>
);
}
form.email = v; setForm(form) не вызовет перерисовку: ссылка на объект не изменилась, а React сравнивает именно ссылки. Всегда создавайте новый объект через расширение.Когда значение нужно глубоко внизу дерева, его приходится передавать через все промежуточные компоненты, которым оно не нужно (это называют prop drilling). Контекст позволяет положить значение наверху и прочитать внизу напрямую.
// 1. Создаём контекст со значением по умолчанию
const ThemeContext = createContext('light');
// 2. Отдаём значение поддереву
function App() {
const [theme, setTheme] = useState('light');
// объект создаётся заново на каждом рендере — мемоизируем,
// иначе все потребители перерисуются даже без реальных изменений
const value = useMemo(() => ({ theme, setTheme }), [theme]);
return <ThemeContext.Provider value={value}><Layout /></ThemeContext.Provider>;
}
// 3. Читаем на любой глубине
function ThemeToggle() {
const { theme, setTheme } = useContext(ThemeContext);
return <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>{theme}</button>;
}
value={{ user, logout }} создаёт новую ссылку на каждом рендере родителя — потребители обновятся всегда. Отсюда useMemo в примере.Признак, что пора переходить: несколько useState меняются вместе, а правила перехода между состояниями размазаны по обработчикам. Редьюсер собирает эти правила в одном месте и делает невозможные комбинации невыразимыми.
// Было: три состояния, которые нужно держать согласованными вручную
const [loading, setLoading] = useState(false);
const [data, setData] = useState(null);
const [error, setError] = useState(null);
// ничто не мешает получить loading: true и error: 'сбой' одновременно
// Стало: одно состояние, переходы описаны явно
function reducer(state, action) {
switch (action.type) {
case 'load': return { status: 'loading' };
case 'success': return { status: 'ready', data: action.payload };
case 'failure': return { status: 'error', message: action.error };
default: return state;
}
}
const [state, dispatch] = useReducer(reducer, { status: 'idle' });
// dispatch({ type: 'load' }) — компонент не знает, как устроен переход
| Берите useState | Берите useReducer |
|---|---|
| Одно-два независимых значения | Несколько значений меняются согласованно |
| Переход очевиден из места вызова | Переходы образуют конечный автомат |
| Логика помещается в обработчик | Логику хочется покрыть тестами отдельно от компонента |
Math.random() внутри. Те же аргументы — тот же результат. Это делает его тестируемым без React и предсказуемым в строгом режиме, который вызывает редьюсер дважды.У useRef две разные работы, и обе сводятся к одному: это изменяемая ячейка, которая не вызывает перерисовку.
// 1. Доступ к узлу DOM
const inputRef = useRef(null);
useEffect(() => { inputRef.current?.focus(); }, []);
<input ref={inputRef} />
// 2. Хранилище между рендерами: таймеры, предыдущее значение, флаги
const timerRef = useRef(null);
function start() {
timerRef.current = setInterval(tick, 1000);
}
useEffect(() => () => clearInterval(timerRef.current), []); // очистка
| useState | useRef | |
|---|---|---|
| Изменение вызывает рендер | Да | Нет |
| Значение между рендерами | Сохраняется | Сохраняется |
| Чтение во время рендера | Можно | Нежелательно |
| Для чего | Данные, влияющие на разметку | Всё, что на разметку не влияет |
ref.current и ждёте обновления интерфейса — его не будет, React о записи не знает. Если значение видно пользователю, это состояние.Самая частая ошибка новичка — рисовать только удачный случай. У любого экрана с запросом к серверу состояний как минимум четыре, и каждое должно быть нарисовано.
| Состояние | Что видит пользователь | Что забывают |
|---|---|---|
| Загрузка | Скелетон или индикатор | Прыжок вёрстки, когда данные пришли |
| Ошибка | Текст и кнопка «Повторить» | Экран остаётся пустым навсегда |
| Пусто | «Пока ничего нет» и подсказка | Выглядит как поломка |
| Данные | Собственно содержимое | — |
if (status === 'loading') return <Skeleton rows={5} />;
if (status === 'error') return <ErrorBox message={message} onRetry={reload} />;
if (!items.length) return <Empty hint="Добавьте первый заказ" />;
return <OrderList items={items} />;
Если писать загрузку руками, необходимы две вещи: отмена устаревшего запроса и защита от записи в размонтированный компонент.
function useOrders(userId) {
const [state, setState] = useState({ status: 'loading' });
useEffect(() => {
const ac = new AbortController();
setState({ status: 'loading' });
fetch(`/api/users/${userId}/orders`, { signal: ac.signal })
.then(r => {
// fetch не считает 404 и 500 ошибкой — проверяем сами
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then(data => setState({ status: 'ready', data }))
.catch(err => {
if (err.name === 'AbortError') return; // это мы сами отменили
setState({ status: 'error', message: err.message });
});
return () => ac.abort(); // userId сменился или компонент ушёл
}, [userId]);
return state;
}
userId придёт последним и перетрёт правильные данные. Экран покажет чужие заказы.r.ok страница HTML с ошибкой уедет в r.json() и упадёт уже там, с непонятным сообщением про синтаксис.useEffect(async () => …) вернёт промис вместо функции очистки, и React предупредит об этом. Объявляйте асинхронную функцию внутри и вызывайте её.Приведённый хук решает один экран. В приложении появляются кэш, повторные попытки, обновление в фоне, инвалидация после изменения данных — и это уже отдельная задача. Её закрывают TanStack Query (React Query) или RTK Query.
// То же самое через TanStack Query
const { data, isPending, isError, refetch } = useQuery({
queryKey: ['orders', userId], // ключ кэша: сменился userId — новый запрос
queryFn: ({ signal }) => fetch(`/api/users/${userId}/orders`, { signal })
.then(r => r.ok ? r.json() : Promise.reject(new Error(r.status))),
staleTime: 30_000, // 30 секунд данные считаются свежими
});
| Что даёт библиотека | Что придётся писать руками |
|---|---|
| Кэш по ключу и дедупликация одинаковых запросов | Свой словарь запросов и проверка «уже грузим» |
| Повтор при сбое и обновление при возврате на вкладку | Счётчик попыток, подписка на visibilitychange |
| Инвалидация после отправки формы | Ручной перезапрос во всех местах, где показаны данные |
| Единое состояние загрузки для всего приложения | Свой флаг в каждом компоненте |
useState. Попытка хранить и то и другое в одном глобальном хранилище — источник большей части сложности в старых проектах на Redux.Исключение при рендере размонтирует всё дерево — пользователь увидит белый экран. Граница ошибок перехватывает сбой поддерева и рисует запасной интерфейс.
<ErrorBoundary fallback={<p>Виджет временно недоступен</p>}>
<RevenueChart />
</ErrorBoundary>
try/catch самостоятельно.TypeScript не выполняется. Он проверяет типы и стирает их, оставляя обычный JavaScript. Отсюда главное следствие: ни одна аннотация не существует во время работы программы — проверить тип данных, пришедших с сервера, типами нельзя.
// Исходник на TypeScript
interface User { id: number; name: string; }
function greet(u: User): string { return `Привет, ${u.name}`; }
// Что останется после компиляции — интерфейс исчез целиком
function greet(u) { return `Привет, ${u.name}`; }
"strict": true включает набор проверок разом. Без него TypeScript теряет большую часть пользы: null проходит всюду, а необъявленные типы молча становятся any.
| Флаг внутри strict | Что ловит |
|---|---|
strictNullChecks | Главный. Без него null и undefined допустимы в любом типе, и обращение к полю отсутствующего объекта не будет ошибкой |
noImplicitAny | Параметр без аннотации, тип которого вывести не удалось |
strictFunctionTypes | Несовместимую подстановку функции вместо другой функции |
strictPropertyInitialization | Поле класса, которое объявлено, но никогда не присваивается |
useUnknownInCatchVariables | Переменная в catch получает unknown, а не any — исключение придётся проверить |
Полезны и те, что в strict не входят:
| Флаг | Зачем |
|---|---|
noUncheckedIndexedAccess | Доступ по индексу даёт T | undefined. Снимает целый класс ошибок с массивами и словарями |
noImplicitOverride | Переопределение метода родителя требует слова override |
exactOptionalPropertyTypes | Различает «поля нет» и «поле равно undefined» |
verbatimModuleSyntax | Требует import type для импорта только типов — важно для корректной сборки |
// Чем помогает noUncheckedIndexedAccess
const ids: number[] = [];
const first = ids[0];
// без флага: number → first.toFixed() компилируется и падает в рантайме
// с флагом: number | undefined → компилятор требует проверку
if (first !== undefined) first.toFixed();
strict разом на большой кодовой базе — тысячи ошибок и соблазн всё отключить. Включают по одному флагу, начиная с noImplicitAny, либо держат строгость только для новых файлов.| Тип | Смысл | Когда уместен |
|---|---|---|
any | Проверки отключены. Любая операция разрешена | Почти никогда. Точечно при переводе старого кода |
unknown | Значение есть, но тип неизвестен. Ничего делать нельзя, пока не проверите | Ответ сети, JSON.parse, переменная в catch |
never | Значения не бывает в принципе | Функция, которая всегда бросает; проверка полноты switch |
// any заражает всё вокруг — ошибка всплывёт далеко от причины
const data: any = JSON.parse(raw);
data.user.profile.name.trim(); // компилируется; падает в рантайме
// unknown заставляет проверить
const data: unknown = JSON.parse(raw);
if (typeof data === 'object' && data !== null && 'user' in data) {
// здесь TypeScript уже знает, что поле есть
}
// never ловит забытую ветку при добавлении нового варианта
function assertNever(x: never): never { throw new Error('Необработанный вариант: ' + x); }
value as User ничего не проверяет, а лишь приказывает компилятору замолчать. Если данные другие, ошибка просто переедет дальше по коду и станет труднее для поиска.interface | type | |
|---|---|---|
| Расширение | extends | Пересечение & |
| Слияние объявлений | Да — два одноимённых объединяются | Нет, будет ошибка |
| Union | Нет | Да — 'a' | 'b' |
| Примитивы, кортежи | Нет | Да |
interface для формы объекта, который могут расширять; type для union, кортежей и вычисляемых типов. Слияние объявлений у interface — то, чем пользуются библиотеки, чтобы дать тебе дописать их типы.// T выводится из аргумента — возвращаемый тип совпадёт с типом элементов
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
first([1, 2, 3]); // number | undefined
first(['a', 'b']); // string | undefined
// Ограничение: принимать только то, у чего есть id
function byId<T extends { id: string }>(items: T[]): Record<string, T> {
return Object.fromEntries(items.map(i => [i.id, i]));
}
TypeScript выводит тип сам, и дублировать очевидное не нужно. Аннотация нужна там, где вывод невозможен или где вы хотите зафиксировать контракт.
// Избыточно — тип и так ясен
const name: string = 'Иван';
// Нужно: аргументы функции вывести неоткуда
function total(items: Item[], rate: number) { … }
// Нужно: фиксируем возвращаемый тип, чтобы случайная правка
// внутри функции не изменила её контракт незаметно
function toDto(u: User): UserDto { … }
// Разница, о которую спотыкаются: let расширяет литеральный тип
let status = 'idle'; // string
const status2 = 'idle'; // 'idle' — литеральный тип
// as const фиксирует всю структуру целиком
const config = { mode: 'dark', retries: 3 } as const;
// { readonly mode: 'dark'; readonly retries: 3 }
const opts = { mode: 'dark' } имеет тип { mode: string }, и передать его туда, где ждут { mode: 'dark' | 'light' }, не получится. Лечится через as const или аннотацией переменной.Без ограничения параметр типа — это «что угодно», и внутри функции с ним ничего нельзя сделать. extends сужает допустимые типы, сохраняя при этом конкретику на выходе.
// Без ограничения: обратиться к .length нельзя — вдруг это число
function first<T>(x: T) { return x.length; } // ошибка
// С ограничением: требуем наличие length
function first<T extends { length: number }>(x: T) { return x.length; }
// keyof — ключ точно существует у объекта, а тип значения выводится
function pluck<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: 1, name: 'Иван' };
const id = pluck(user, 'id'); // number
const no = pluck(user, 'email'); // ошибка компиляции: такого ключа нет
sortBy(rows, 'createdAt') проверит, что поле существует, и сам выведет его тип. Без ограничений пришлось бы писать any и потерять проверку.| Тип | Что делает | Пример применения |
|---|---|---|
Partial<T> | Все поля необязательные | DTO для PATCH-запроса |
Required<T> | Все поля обязательные | Конфиг после применения дефолтов |
Pick<T, K> | Взять перечисленные поля | Публичный профиль из модели пользователя |
Omit<T, K> | Убрать перечисленные | Модель без password и id |
Record<K, V> | Словарь с заданными ключами | Нормализованный стор: Record<string, Chat> |
ReturnType<F> | Тип возврата функции | RootState = ReturnType<typeof store.getState> |
Awaited<T> | Тип внутри промиса | Результат асинхронной функции |
Omit и Partial. Иначе три похожих интерфейса разъезжаются при первой же правке.Утилитарные типы — не магия компилятора, а обычные обобщённые типы, написанные на том же языке типов. Понимание их устройства позволяет писать свои.
// Так объявлены Partial и Readonly в стандартной библиотеке
type Partial<T> = { [K in keyof T]?: T[K] };
type Readonly<T> = { readonly [K in keyof T]: T[K] };
// Pick перебирает только указанные ключи
type Pick<T, K extends keyof T> = { [P in K]: T[P] };
// Условный тип: ветвление на уровне типов
type Unwrap<T> = T extends Promise<infer U> ? U : T;
type A = Unwrap<Promise<User>>; // User
type B = Unwrap<number>; // number
// Сделать часть полей обязательными, остальные оставить как есть
type RequireFields<T, K extends keyof T> = T & Required<Pick<T, K>>;
// Черновик заказа, у которого точно есть идентификатор клиента
type DraftOrder = RequireFields<Partial<Order>, 'customerId'>;
// Глубокая неизменяемость — для конфигов и замороженных данных
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
// 1. typeof — для примитивов
if (typeof value === 'string') { value.toUpperCase(); }
// 2. in — по наличию поля
if ('error' in result) { report(result.error); }
// 3. Discriminated union — поле-дискриминатор
type Result =
| { status: 'ok'; data: User }
| { status: 'error'; message: string };
if (res.status === 'ok') {
res.data; // TypeScript знает: здесь есть data
} else {
res.message; // а здесь message
}
status, kind, type) разводит ветки, и компилятор сам понимает, какие поля доступны в каждой. Это же позволяет проверять полноту switch через never.const raw: any = JSON.parse(body);
raw.user.name.toUpperCase(); // компилируется, падает в рантайме
const raw: unknown = JSON.parse(body);
raw.user; // ошибка компиляции — заставит проверить
any отключает проверки молча и заразно. Любое выражение с any тоже становится any, и дыра расползается по коду. unknown безопасен: с ним ничего нельзя сделать, пока не сузишь тип.Встроенные typeof и in покрывают не всё. Для собственных проверок есть предикат — функция, чей возвращаемый тип записан как arg is Type.
// Обычная функция: вернула true, но компилятор ничего нового не узнал
function isUser(x: unknown): boolean { … }
// Предикат: после проверки тип сужается
function isUser(x: unknown): x is User {
return typeof x === 'object' && x !== null
&& 'id' in x && typeof (x as User).id === 'number';
}
if (isUser(data)) {
data.id; // здесь это User
}
// Частый случай: отфильтровать пустые значения из массива
const clean = list.filter((x): x is string => x != null);
// без предиката тип остался бы (string | null)[]
Сужение живёт до тех пор, пока компилятор уверен, что значение не могло измениться. Есть несколько мест, где эта уверенность пропадает.
interface Box { value?: string }
function bad(box: Box) {
if (box.value) {
setTimeout(() => box.value.trim(), 0);
// ошибка: между проверкой и вызовом поле могли обнулить
}
}
function good(box: Box) {
const value = box.value; // копия в константу
if (value) {
setTimeout(() => value.trim(), 0); // сужение сохраняется
}
}
| Где теряется | Почему |
|---|---|
| Внутри колбэка или замыкания | Код выполнится позже, поле могли изменить |
| После вызова любой функции | Компилятор считает, что она могла поменять объект |
| У свойства изменяемого объекта | Надёжно сужаются локальные константы, а не поля |
После await | За время ожидания состояние могло измениться |
Это главный приём, ради которого стоит переходить на TypeScript всерьёз. Вместо набора независимых полей — перечисление допустимых вариантов, где у каждого свой набор данных. Невозможная комбинация перестаёт компилироваться.
// Четыре поля дают 16 комбинаций, осмысленных — три
interface State {
loading: boolean;
data?: Order[];
error?: string;
}
// { loading: true, error: 'сбой', data: [...] } — тип это допускает
// Размеченное объединение: поле-метка status различает варианты
type State =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'ready'; data: Order[] }
| { status: 'error'; message: string };
Теперь компилятор сам проводит по веткам и в каждой знает, какие поля доступны:
function render(state: State) {
switch (state.status) {
case 'idle':
case 'loading': return <Skeleton />;
case 'ready': return <List items={state.data} />; // data точно есть
case 'error': return <Error text={state.message} />; // message точно есть
}
}
// state.data в ветке 'error' — ошибка компиляции, а не падение у пользователя
data! с восклицательным знаком или as — значит, вы знаете о данных больше, чем тип. Почти всегда это означает, что тип описан через необязательные поля там, где нужно объединение.Через год в объединение добавят новый вариант. Нужно, чтобы компилятор показал все места, которые его не обрабатывают, — иначе ошибка найдётся на проде.
type Payment =
| { kind: 'card'; last4: string }
| { kind: 'cash' }
| { kind: 'crypto'; chain: string }; // ← добавили позже
function label(p: Payment): string {
switch (p.kind) {
case 'card': return `Карта •••• ${p.last4}`;
case 'cash': return 'Наличные';
default: {
// если какой-то вариант не обработан, он «дотечёт» сюда
// и не подойдёт под never — будет ошибка компиляции
const rest: never = p;
throw new Error(`Неизвестный способ оплаты: ${JSON.stringify(rest)}`);
}
}
}
// Ошибка: Type '{ kind: "crypto"; chain: string; }' is not assignable to type 'never'
kind: string, сужение не работает — TypeScript не может различить варианты. Нужны именно строковые литералы.В TypeScript есть enum, но в современном коде чаще берут объединение строковых литералов: оно не оставляет следа после компиляции, проще сериализуется и лучше читается в отладчике.
| enum | Объединение литералов | |
|---|---|---|
| Код после сборки | Создаёт объект в бандле | Исчезает полностью |
| Значение в отладчике | Число, если не строковый enum | Читаемая строка |
| Сравнение с данными API | Нужно приводить | Напрямую |
| Перебор вариантов | Встроенный | Через массив as const |
const ROLES = ['admin', 'editor', 'viewer'] as const;
type Role = typeof ROLES[number]; // 'admin' | 'editor' | 'viewer'
// Массив можно перебирать, тип выводится из него — источник один
ROLES.forEach(r => console.log(r));
isolatedModules, который включён в сборщиках на основе esbuild (Vite). В новом коде его лучше не использовать.const user: User = await res.json() — это обещание, а не проверка.| Типы | Валидация | |
|---|---|---|
| Когда работает | Компиляция | Рантайм |
| От чего защищает | От ошибок в своём коде | От неверных внешних данных |
| Инструменты | tsc | zod, class-validator, DTO |
// zod: схема одновременно проверяет и ВЫВОДИТ тип
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
age: z.number().int().min(0),
});
type User = z.infer<typeof UserSchema>; // тип из схемы, не дублируем
const user = UserSchema.parse(await res.json()); // бросит, если данные не те
class-validator: тип описывает форму, декораторы проверяют значение в рантайме. Одно без другого неполно.Валидировать всё подряд дорого и неудобно. Практичный подход: проверять данные ровно один раз — на входе в приложение, — а дальше работать с уже доверенными типами.
| Проверять обязательно | Проверять не нужно |
|---|---|
| Ответы внешних сервисов | Данные, которые вы только что сами и составили |
| Тело запроса от клиента | Внутренние вызовы между своими функциями |
localStorage и cookie | Константы в коде |
| Параметры адреса | Результат уже провалидированной схемы |
Описывать форму данных дважды — отдельно интерфейс и отдельно проверку — верный способ получить расхождение. Схема решает обе задачи сразу: проверяет и выводит тип.
import { z } from 'zod';
const OrderSchema = z.object({
id: z.number().int().positive(),
status: z.enum(['new', 'paid', 'shipped']),
total: z.number().nonnegative(),
comment: z.string().optional(),
});
// Тип выводится из схемы — второго описания не существует
type Order = z.infer<typeof OrderSchema>;
async function loadOrder(id: number): Promise<Order> {
const res = await fetch(`/api/orders/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
// safeParse не бросает — удобно показать понятную ошибку
const parsed = OrderSchema.safeParse(await res.json());
if (!parsed.success) {
report(parsed.error); // бэкенд изменил контракт
throw new Error('Неверный формат ответа');
}
return parsed.data; // здесь данным уже можно верить
}
const o = await res.json() as Order компилируется всегда. Если сервер переименовал поле, ошибка вылезет в компоненте при отрисовке — в месте, по которому причину уже не найти.// Пропсы компонента
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'ghost';
loading?: boolean;
}
export function Button({ variant = 'primary', loading, ...rest }: ButtonProps) {
return <button disabled={loading} {...rest} />;
}
// Состояние: явный тип нужен, когда начальное значение не описывает форму
const [user, setUser] = useState<User | null>(null);
const [items, setItems] = useState<Item[]>([]);
// Ссылка на DOM-узел
const inputRef = useRef<HTMLInputElement>(null);
// Событие
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => setText(e.target.value);
ButtonHTMLAttributes) даёт компоненту все стандартные пропсы — onClick, disabled, type — без ручного перечисления.Типы событий React именованы по виду события и типу элемента. Проще всего не писать их вручную, а дать вывести из места использования.
// Обработчик объявлен прямо в разметке — тип выводится сам
<input onChange={e => setValue(e.target.value)} />
// Вынесли в отдельную функцию — тип нужно указать
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setValue(e.target.value);
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
}
// Ссылки на узлы: начальное значение null, отсюда и проверка перед обращением
const inputRef = useRef<HTMLInputElement>(null);
inputRef.current?.focus();
| Что типизируем | Тип |
|---|---|
| Содержимое компонента | React.ReactNode |
| Обработчик без параметров | () => void |
| Клик по кнопке | React.MouseEvent<HTMLButtonElement> |
| Все атрибуты элемента | React.ComponentProps<'button'> |
children даже компонентам, которые их не принимают, и мешает дженерикам. Сейчас рекомендуют обычную функцию с типизированным объектом props — как в примерах выше.Компонент списка, который должен работать с любыми данными и при этом сохранять их тип в колбэках, объявляется обобщённым.
interface ListProps<T> {
items: T[];
getKey: (item: T) => string | number;
renderItem: (item: T) => React.ReactNode;
}
export function List<T>({ items, getKey, renderItem }: ListProps<T>) {
return <ul>{items.map(i => <li key={getKey(i)}>{renderItem(i)}</li>)}</ul>;
}
// T выводится из items — внутри renderItem доступны поля Order
<List
items={orders}
getKey={o => o.id}
renderItem={o => <b>{o.total}</b>}
/>
any и потерять подсказки редактора ровно там, где они нужнее всего.Tailwind не даёт готовых компонентов. Он берёт набор значений — цвета, отступы, размеры шрифта, радиусы — и генерирует из них классы. Этот набор называют дизайн-токенами, и именно его настраивают под проект.
/* Tailwind v4: конфигурация прямо в CSS, отдельный tailwind.config.js не нужен */
@import "tailwindcss";
@theme {
/* имя токена определяет имя класса: --color-brand → bg-brand, text-brand */
--color-brand: #ea2845;
--color-surface: #fdfdfd;
--font-display: "Manrope", sans-serif;
--radius-card: 14px;
}
// Tailwind v3: привычный файл конфигурации
// extend добавляет к стандартной палитре, без него она заменяется целиком
export default {
content: ['./src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: { brand: '#ea2845' },
borderRadius: { card: '14px' },
},
},
};
bg-[#e82746], это свойство теряется.Вариант dark: применяет класс в тёмном режиме. Вопрос лишь в том, откуда берётся признак темноты.
| Стратегия | Как включается | Когда подходит |
|---|---|---|
| По системной настройке | Поведение по умолчанию, через медиазапрос | Переключателя в интерфейсе нет |
| По классу или атрибуту | <html class="dark"> и своё правило варианта | Пользователь выбирает тему сам |
/* v4: объявляем, что тёмная тема включается атрибутом на html */
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
<div className="bg-white text-slate-900 dark:bg-slate-900 dark:text-slate-100">
Карточка, которая умеет обе темы
</div>
localStorage после загрузки React, страница сначала покажется светлой, а потом перекрасится. Лечится маленьким скриптом в <head>, который выставляет атрибут до первой отрисовки — ровно так сделано в этой базе знаний.dark: у каждого элемента утомительно и легко забыть. Надёжнее описать цвета переменными CSS, переопределить их для тёмной темы один раз и ссылаться на токены.Сборщик ищет имена классов текстом по исходникам. Он не выполняет код, поэтому собранное из кусков имя в сборку не попадёт — класс просто не будет существовать.
// Класса не окажется в CSS: такой строки в исходнике нет
const cls = `text-${color}-500`;
// Полные имена классов записаны явно
const COLORS = {
danger: 'text-red-500',
success: 'text-green-500',
} as const;
const cls = COLORS[kind];
| Симптом | Обычная причина |
|---|---|
| Класс не работает только в проде | Имя собиралось из кусков; в разработке спасал полный набор классов |
| Не работает в новой папке | Путь не попал в content (v3) или не просматривается сборщиком (v4) |
| Классы из библиотеки не применяются | Её файлы лежат в node_modules и не просматриваются |
| Два класса конфликтуют | Порядок в атрибуте не решает ничего — выигрывает тот, что идёт позже в CSS. Нужен tailwind-merge |
Когда компонент принимает className снаружи, простая конкатенация даёт конфликт: в разметке будут оба класса, а применится не тот, который ожидали.
import { twMerge } from 'tailwind-merge';
import { clsx } from 'clsx';
// clsx — собирает классы по условиям
// twMerge — убирает конфликтующие, оставляя последний
export function Button({ variant = 'primary', className, ...rest }) {
return (
<button
className={twMerge(clsx(
'rounded-card px-4 py-2 font-semibold transition',
variant === 'primary' && 'bg-brand text-white hover:opacity-90',
variant === 'ghost' && 'bg-transparent text-brand',
), className)}
{...rest}
/>
);
}
// <Button className="px-6" /> → останется px-6, а не оба px-4 и px-6
Вместо написания CSS для каждого блока — готовые утилитарные классы прямо в разметке. Один класс делает одну вещь.
<button class="px-4 py-2 rounded-lg bg-indigo-600 text-white font-medium
hover:bg-indigo-700 disabled:opacity-50">
Отправить
</button>
| Плюс | Минус |
|---|---|
| Не нужно придумывать имена классов | Разметка становится шумной |
| Стиль виден прямо в разметке | Трудно читать длинные строки классов |
| Нет мёртвого CSS — в сборку попадает только использованное | Повторы, если не выносить в компоненты |
| Нет конфликтов специфичности | Порог входа: надо выучить названия |
bg-brand-500 везде означает один и тот же цвет, и поменять его можно в одном месте.Классы отступов и размеров берутся из шкалы, где шаг равен 0.25rem. Это выглядит ограничением, но именно оно даёт согласованный ритм: в интерфейсе не появятся отступы 13px, 14px и 15px рядом.
| Класс | Значение | В пикселях при корне 16px |
|---|---|---|
p-1 | 0.25rem | 4px |
p-2 | 0.5rem | 8px |
p-4 | 1rem | 16px |
p-6 | 1.5rem | 24px |
p-[13px] | произвольное | 13px — выпадает из шкалы |
Произвольные значения в квадратных скобках существуют для редких случаев: подгонка под фон-картинку, чужой виджет, точный размер иконки. Если они встречаются часто — значение стоит добавить в тему как токен.
bg-[#ea2845] в двадцати местах означает, что смена фирменного цвета превратится в поиск по проекту. Токен bg-brand меняется в одном файле.| Обычный CSS и БЭМ | CSS-модули | Tailwind | |
|---|---|---|---|
| Имена классов | Придумываете сами | Придумываете, уникальность автоматом | Не придумываете вовсе |
| Риск конфликта стилей | Высокий, каскад глобальный | Нет | Нет |
| Мёртвый код | Копится незаметно | Копится | Невозможен: в сборку идёт использованное |
| Где смотреть стиль | В другом файле | В другом файле | Прямо в разметке |
| Чем платите | Именованием и каскадом | Переключением между файлами | Длинными строками классов |
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
| Группа | Префиксы | Смысл |
|---|---|---|
| Адаптив | sm: md: lg: xl: | Применить от этой ширины и выше |
| Состояния | hover: focus: active: disabled: | По состоянию элемента |
| Тёмная тема | dark: | При включённой тёмной теме |
| От родителя | group-hover: peer-checked: | Реакция на состояние соседа или родителя |
md: — от средней и выше. Поэтому базовые стили пишут для мобильного, а префиксами расширяют. Попытка сделать наоборот приводит к войне с каскадом.Обычные префиксы смотрят на ширину окна. Но компоненту чаще важна ширина его места: одна и та же карточка в широкой колонке и в узкой боковой панели должна выглядеть по-разному, хотя окно одно.
<div className="@container">
<!-- смотрит на ширину родителя с @container, а не окна -->
<article className="flex flex-col @md:flex-row @md:items-center gap-4">
<img className="w-full @md:w-32 rounded-lg" />
<div>…</div>
</article>
</div>
Два механизма закрывают случаи, ради которых раньше писали JavaScript: group — реакция на состояние родителя, peer — на состояние соседнего элемента.
<!-- group: стрелка реагирует на наведение всей карточки -->
<a className="group flex items-center gap-2 rounded-lg p-4 hover:bg-slate-50">
<span>Открыть раздел</span>
<Arrow className="transition group-hover:translate-x-1" />
</a>
<!-- peer: подсказка показывается, когда поле не прошло проверку -->
<input type="email" required className="peer border rounded px-3 py-2" />
<p className="hidden peer-invalid:block text-sm text-red-600">
Введите корректный адрес
</p>
@apply повсюду. Компонент — естественная единица переиспользования; @apply возвращает ровно ту проблему именованных классов, от которой уходили.// Вместо копирования классов по всему проекту — один компонент
export function Card({ children }: { children: React.ReactNode }) {
return (
<div className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
{children}
</div>
);
}
// Условные классы — через clsx, а не конкатенацию строк
<button className={clsx(
'px-4 py-2 rounded-lg',
variant === 'primary' && 'bg-indigo-600 text-white',
variant === 'ghost' && 'bg-transparent text-indigo-600',
disabled && 'opacity-50 cursor-not-allowed',
)} />
`bg-${color}-500` не сработает: сборщик сканирует исходники по полным именам классов и такой класс просто не попадёт в CSS. Нужны полные имена в коде — в объекте-карте или в условиях.Когда у кнопки появляются вид, размер и состояние, условные выражения в clsx превращаются в кашу. Для этого есть class-variance-authority: варианты описываются таблицей.
import { cva, type VariantProps } from 'class-variance-authority';
const button = cva(
'inline-flex items-center justify-center rounded-lg font-medium transition',
{
variants: {
intent: {
primary: 'bg-brand text-white hover:opacity-90',
ghost: 'bg-transparent text-brand hover:bg-brand/10',
},
size: { sm: 'h-8 px-3 text-sm', md: 'h-10 px-4' },
},
defaultVariants: { intent: 'primary', size: 'md' },
},
);
type Props = React.ComponentProps<'button'> & VariantProps<typeof button>;
export function Button({ intent, size, className, ...rest }: Props) {
return <button className={twMerge(button({ intent, size }), className)} {...rest} />;
}
// Типы вариантов выводятся из описания — опечатка в intent не скомпилируется
Общее правило — выносить повторы в компоненты. Но есть случаи, где компонента не существует и директива оправдана.
| Уместно | Не уместно |
|---|---|
| Базовые стили документа: типографика, фокус, выделение | Воссоздание «своего» фреймворка из .btn и .card |
| Разметка из стороннего HTML, который нельзя править | Всё, что можно оформить компонентом |
| Содержимое из Markdown или редактора | Сокращение «слишком длинной строки классов» |
@layer base {
/* оправданно: применяется ко всем ссылкам в статьях */
.prose a { @apply text-brand underline underline-offset-2; }
}
@apply. Создавая .btn из утилит, вы возвращаетесь ровно к тому, от чего уходили: имя нужно придумать, стиль снова в другом файле, а мёртвый CSS опять копится. Разница лишь в том, что теперь это написано утилитами.state старого рендера. Лечится честными зависимостями или setState(prev => ...).useEffect без массива зависимостей. Запускается после каждой перерисовки — и часто сам же её и вызывает.key по индексу массива. При вставке в середину React переиспользует не те узлы — состояние элементов списка «съезжает».useMemo на всём подряд усложняет код и может быть дороже самого вычисления.any как способ «починить» ошибку. Отключает проверки и расползается дальше по коду. Используй unknown и сужение.await res.json() возвращает что угодно. Внешние данные проверяются в рантайме.as вместо проверки. as User ничего не проверяет — это указание компилятору замолчать.Omit, Partial, Pick.`text-${size}` не попадёт в сборку — сканер ищет полные имена.@apply вместо компонентов. Возвращает именованные классы и их проблемы, ради которых Tailwind и брали.p-[13px] и bg-[#1a2b3c] в каждом файле ломают единообразие. Место таких значений — в конфиге.