Структура и компоненты проекта
Структура проекта
Прежде чем начать полноценную работу, давайте определимся со структурой внутри нашей рабочей директории проекта. Так как проект у нас относительно небольшой и много страниц, между которыми надо будет активно переключаться, содержать не будет, рекомендую обратить внимание на folder-by-type (technical slices), где файлы организуются по следующим принципам:
-
-
-
- (глобальные стили, CSS-переменные, reset/normalize)
-
-
-
- Button.tsx
- Button.test.tsx
- Button.stories.tsx
- index.ts
-
-
-
- HomePage.tsx
- index.ts
-
-
- http.ts (axios/fetch instance, интерцепторы)
- userApi.ts (getUser, updateUser, …)
- index.ts
-
- date.ts
- number.ts
- index.ts
-
- useDebounce.ts
- useLocalStorage.ts
- index.ts
-
- user.store.ts
- index.ts
-
- index.tsx (lazy/imports, guards)
-
- env.ts (чтение VITE_* переменных)
- constants.ts
-
- global.d.ts
- main.tsx (точка входа, ReactDOM.createRoot)
- App.tsx (корневой layout, провайдеры)
-
Кратко по слоям:
- assets/styles/ — статичные ресурсы и глобальные стили, CSS-переменные, reset/normalize.
- components/ — переиспользуемые презентационные компоненты (без бизнес-логики).
- pages/ — маршрутные компоненты, «экраны» приложения.
- api/ — HTTP-клиент, запросы, DTO/adapter’ы.
- utils/ — чистые функции без зависимостей от React/DOM.
- hooks/ — переиспользуемые React-хуки.
- store/ — глобальное состояние (Zustand/Redux), селекторы.
- routes/ — конфигурация роутера.
- config/ — конфигурация окружения, флаги, константы.
- types/ — общие типы/интерфейсы TS.
Базовый обзор JSX-компонента
Для начала разберём стартовый файл src/App.tsx, который автоматически сгенерировался вместе с базовым шаблоном React-проекта.
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import viteLogo from "/vite.svg";
// Глобальное подключение CSS-файла: можно использовать для сброса базовых стилей,
// настройки типографики и т.д.
// Для стилизации компонентов лучше использовать локализованные стили (далее рассмотрим).
import "./App.css";
function App() {
// Локальное состояние (число кликов). React повторно вызовет компонент при setCount().
// Для его создания используется хук useState — подробнее см.
// https://react.dev/reference/react/useState
const [count, setCount] = useState(0);
return (
// Каждый JSX-элемент может быть только с ОДНИМ корневым узлом.
// Фрагмент <></> (https://react.dev/reference/react/Fragment) позволяет
// сгруппировать JSX-код, не создавая лишнего DOM-узла.
<>
{/* Статические ассеты в Vite импортируются как URL */}
<div>
<a href="https://vite.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
{/* Обновление счётчика на странице безопасным способом (подробнее ниже) */}
<button onClick={() => setCount((prev) => prev + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
);
}
export default App;Обратим внимание на несколько нюансов.
Во-первых, посмотрим на то, как организована структура импортов. viteLogo импортируется из "/vite.svg" — это «абсолютный» путь от корня dev-сервера Vite. А для reactLogo используется относительный импорт из ./assets/react.svg. Оба метода корректны, но в продакшене Vite всё равно перепакует их в хешированные файлы (logo.abc123.svg) и подставит обновлённые варианты URL.
Также определённый интерес вызывает функция счётчика. Лично у меня бы возник вопрос, почему его нельзя увеличивать просто как setCount(count + 1)? Основная проблема заключается в том, что мы не можем в таком случае гарантировать, что значение count ещё не успело устареть, так как обновления могут совершаться очень быстро. Поэтому конструкция useState(0) + setCount(prev => prev + 1) — безопасная форма. Она гарантирует, что мы обновляем актуальную версию состояния.
main.tsx, вы там увидите, что App обёрнут в <React.StrictMode>. В режиме разработки он намеренно монтирует компоненты дважды, чтобы поймать возможные сайд-эффекты. В режиме релиза этот двойной вызов отключается.Как было отмечено ранее, несомненное преимущество React — компонентный подход, благодаря чему мы можем делить единую страницу на набор переиспользуемых компонентов. Это не только упрощает жизнь разработчика, но и повышает производительность нашего продукта. При делении элемента на компоненты мы уменьшаем количество лишних перерендеров.
useMemo) происходит перерендер (перерисовка) всего компонента целиком. Путём деления на отдельные компоненты мы уменьшаем область этой перерисовки и сохраняем производительность.Button
Возникает закономерный вопрос — как вынести повторяющиеся или значимые куски в отдельные компоненты, что позволит достичь изоляции ответственности и переиспользовать их в дальнейшем? В качестве примера спроектируем собственную кнопку с небольшим API (Application Programming Interface) с использованием базовых правил и хороших практик.
Структуру её файлов организуем следующим образом:
-
-
-
- Button.tsx
- Button.module.css
- index.ts
-
-
Как мы видим, в названии стилей кнопки появляется приписка .module. Если мы будем продолжать складывать всё в один общий App.css, тогда рано или поздно появится конфликт имён классов: .button в одном месте и .button в другом — это один и тот же глобальный селектор. Чтобы этого избежать, появляется концепция CSS-модулей: имена классов становятся локальными для файла и преобразуются сборкой в уникальные.
/* Эти стили принадлежат только компоненту Button: одинаковые имена в других
компонентах не пересекутся, потому что сборка превратит их в уникальные. */
.button {
padding: 8px 12px;
border: none;
border-radius: 10px;
cursor: pointer;
}
/* Варианты оформления: переключаются пропом variant */
.primary { background: var(--color-primary, #3b82f6); color: #fff; }
.secondary { background: #eee; color: #222; }
/* Размеры: переключаются пропом size */
.sm { font-size: 14px; }
.md { font-size: 16px; }
.lg { font-size: 18px; }
/* Отключаем события кликов по «выключенной» кнопке */
.disabled {
opacity: .6;
pointer-events: none;
}Имена классов в CSS-модулях будут локализоваться (например, button превратится в button__2k4f1), поэтому .button можно повторять в других компонентах — конфликтов имён не будет.
--color-primary позволяет позже переключать тему на уровне :root в глобальном CSS, а компоненты уже автоматически считают изменения.Стили готовы — теперь сама кнопка. Перед этим также не забудем установить библиотеку clsx, которая используется для объединения нескольких CSS-классов. Установить её можно прописав в консоли: npm i clsx.
import { type ReactNode } from "react";
// Подключаем ранее упомянутую библиотеку clsx
import clsx from "clsx";
// Импорт CSS-модуля даёт объект "s" со свойствами-классами (s.button, s.primary, ...).
import s from "./Button.module.css";
/**
* Допустимые значения визуального варианта кнопки.
* Здесь используется операция объединения (union):
* https://www.typescriptlang.org/docs/handbook/unions-and-intersections.html
*/
type ButtonVariant = "primary" | "secondary";
/** Допустимые размеры кнопки. */
type ButtonSize = "sm" | "md" | "lg";
/**
* Пропсы кнопки.
* - children: контент внутри (текст, иконка и пр.)
* - variant/size: опциональные, с умолчаниями ("primary", "md")
* - disabled: делает кнопку неактивной
* - className: позволяет доклеить свои классы (например, от родителя)
* - onClick: опциональный обработчик клика
*
* Опциональность достигается за счёт указанного рядом с пропсом вопросительного знака.
*/
interface ButtonProps {
children: ReactNode;
variant?: ButtonVariant;
size?: ButtonSize;
disabled?: boolean;
className?: string;
onClick?: () => void; // при желании можно сузить до React.MouseEventHandler<HTMLButtonElement>
}
/**
* Универсальная кнопка.
* При помощи знака `=` сразу указываем значения «по умолчанию» для части параметров.
*/
export function Button({
children,
variant = "primary", // дефолтный вариант
size = "md", // дефолтный размер
disabled = false, // по умолчанию кнопка активна
className,
onClick,
}: ButtonProps) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={clsx(
s.button,
s[variant], // s.primary или s.secondary
s[size], // s.sm, s.md или s.lg
disabled && s.disabled,
className // внешние пользовательские классы
)}
>
{children}
</button>
);
}Расширенная версия: принимать все нативные атрибуты
Также можно сделать, чтобы <Button> принимал все нативные (базовые) атрибуты <button>. Для этого необходимо расширить набор базовых свойств:
import { type ComponentPropsWithoutRef } from "react";
type ButtonVariant = "primary" | "secondary";
type ButtonSize = "sm" | "md" | "lg";
// Базовые нативные пропсы кнопки (type, autoFocus, aria-*, и т.д.)
type NativeButtonProps = ComponentPropsWithoutRef<"button">;
interface ButtonProps extends NativeButtonProps {
variant?: ButtonVariant;
size?: ButtonSize;
// className, disabled и children уже есть в NativeButtonProps,
// дублировать не обязательно
}
export function Button({
children,
variant = "primary",
size = "md",
type = "button", // по умолчанию button, но можем переопределить на submit
...rest // ← onClick, disabled, aria-*, data-* и т.д.
}: ButtonProps) {
return (
<button
type={type}
{...rest}
className={clsx(
s.button,
s[variant],
s[size],
rest.disabled && s.disabled,
rest.className
)}
>
{children}
</button>
);
}Чтобы этот компонент было удобнее импортировать, рядом создадим файл index.ts, который будет отвечать за реэкспорт модулей Button во весь остальной проект, что сделает импорты существенно более аккуратными.
export * from "./Button";index.ts файлы может расширение TypeScript Barrel для VS Code.Дальше возвращаемся в App.tsx и начинаем пользоваться своей кнопкой. Первый вызов оставим без параметров, чтобы увидеть, как работают дефолтные variant и size. Во втором вызове явно переключим оформление и отключим кнопку:
import { useState } from "react";
// Используем ранее настроенный алиас @ → src
import { Button } from "@/components/Button";
import "./App.css";
function App() {
const [count, setCount] = useState(0);
return (
<main style={{ display: "grid", gap: 16 }}>
<h1>Vite + React</h1>
{/* Состояния по умолчанию: variant="primary", size="md" */}
<Button onClick={() => setCount((c) => c + 1)}>
Кликнули {count} раз
</Button>
{/* Передаём явные значения и включаем блокировку */}
<Button variant="secondary" size="lg" disabled>
Большая неактивная
</Button>
</main>
);
}
export default App;Header
Теперь, для закрепления, сделаем Header (шапку нашего проекта). Сначала определим типы (контракт между интерфейсом и логикой) и конфигурацию компонента (хранение навигации вне JSX сильно упрощает изменение подписей и порядок вкладок без изменения разметки страницы).
Для отображения иконок воспользуемся пакетом MUI — популярной библиотекой готовых UI-компонентов для React (кнопки, поля ввода, темы, иконки), которая дальше будет ещё также задействоваться для упрощения кастомизации и создания новых стилизованных компонентов без лишней головной боли. Установить его можно следующим образом:
npm i @mui/icons-material @mui/material @emotion/react @emotion/styledimport { type SvgIconComponent } from "@mui/icons-material";
// Базовая информация, которую храним о каждой из навигационных вкладок
export interface NavItem {
id: string;
label: string;
icon?: SvgIconComponent;
href?: string;
}
// Превью аватарки пользователя
export interface UserBrief {
name: string;
avatarUrl?: string;
}Для настройки конфигурации навигационных вкладок меню используем ранее созданный интерфейс NavItem:
import type { NavItem } from "./header.types";
import { ListAltOutlined, EventNoteOutlined, SettingsOutlined } from "@mui/icons-material";
export const DEFAULT_NAV: NavItem[] = [
{ id: "catalog", label: "Каталог аудиторий", icon: ListAltOutlined },
{ id: "bookings", label: "Управление бронированием", icon: EventNoteOutlined },
{ id: "settings", label: "Настройки", icon: SettingsOutlined },
];Теперь создадим файл с функциями-хелперами, которые тоже никак не завязаны на React. Там разместим функцию для определения инициалов, если пользователь не установил самостоятельно аватарку:
export function getInitials(name: string | undefined) {
if (!name) return "NN";
const [a = "", b = ""] = name.trim().split(/\s+/);
return (a[0] ?? "").concat(b[0] ?? "").toUpperCase();
}Теперь можно перейти к разметке и объединению логики поведения всего компонента в целом — в Header.tsx. Стили, как и раньше, разместим рядом в CSS-модуле.
.header { position: sticky; top: 0; background: #fff; border-bottom: 1px solid #eef0f3; z-index: 10; }
.row { display: flex; align-items: center; gap: 16px; padding: 10px 16px; max-width: 1200px; margin: 0 auto; }
.brand { display: flex; align-items: center; gap: 10px; color: #0f172a; font-weight: 700; }
.logoBox { width: 32px; height: 32px; border-radius: 10px; background: #1d4ed8; color: #fff; display: grid; place-items: center; }
.logoIc { width: 18px; height: 18px; }
.app { font-size: 18px; }
.nav { display: flex; align-items: center; gap: 8px; margin-left: 8px; }
.tab { display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px; border-radius: 999px; border: none; background: transparent; color: #334155; cursor: pointer; }
.tab:focus-visible { outline: 3px solid rgba(99,102,241,.25); outline-offset: 2px; border-radius: 12px; }
.tabActive { background: #1d4ed8; color: #fff; }
.tabIc { width: 16px; height: 16px; }
.spacer { flex: 1; }
.right { display: flex; align-items: center; gap: 12px; }
.iconBtn { background: transparent; border: none; padding: 6px; border-radius: 10px; color: #334155; cursor: pointer; }
.iconBtn:active { transform: translateY(1px); }
.avatar { width: 32px; height: 32px; border-radius: 999px; background: #cdd5df; display: grid; place-items: center; color: #334155; font-weight: 700; }
.userNm { font-size: 14px; color: #334155; }import clsx from "clsx";
import s from "./Header.module.css";
import type { NavItem, UserBrief } from "./header.types";
import { DEFAULT_NAV } from "./header.config";
import { getInitials } from "./header.utils";
import { DomainRounded, NotificationsNoneOutlined } from "@mui/icons-material";
// Публичный API шапки.
// - navItems: массив вкладок; если не передан — берём DEFAULT_NAV
// - activeNavId: id активной вкладки (подсветка + aria-current)
// - onNavigate: колбэк при клике по вкладке
// - user: данные пользователя (для аватара/инициалов)
// - onBellClick: клик по колокольчику (уведомления)
export interface HeaderProps {
navItems?: NavItem[];
activeNavId: string;
onNavigate: (id: string) => void;
user?: UserBrief;
onBellClick?: () => void;
}
export function Header({
navItems = DEFAULT_NAV,
activeNavId,
onNavigate,
user,
onBellClick,
}: HeaderProps) {
return (
<header className={s.header}>
<div className={s.row}>
{/* Левая зона: логотип + название приложения */}
<div className={s.brand}>
<div className={s.logoBox} aria-hidden>
<DomainRounded className={s.logoIc} />
</div>
<div className={s.app}>Room Booking</div>
</div>
{/* Центральная зона: навигация по вкладкам.
aria-label даёт подпись навигации для скринридеров. */}
<nav className={s.nav} aria-label="Основная навигация">
{navItems.map((item) => {
const active = item.id === activeNavId;
return (
// Кнопки вместо <a>: SPA-навигация, состоянием управляет родитель.
// При желании можно заменить на <NavLink/>
<button
key={item.id}
type="button"
className={clsx(s.tab, active && s.tabActive)}
onClick={() => onNavigate(item.id)}
aria-current={active ? "page" : undefined}
title={item.label}
>
{item.icon && <item.icon className={s.tabIc} />}
<span>{item.label}</span>
</button>
);
})}
</nav>
<div className={s.spacer} />
{/* Правая зона: действия и пользователь */}
<div className={s.right}>
<button
type="button"
className={s.iconBtn}
onClick={onBellClick}
aria-label="Уведомления"
title="Уведомления"
>
<NotificationsNoneOutlined />
</button>
{/* Аватар пользователя.
Если есть avatarUrl — показываем картинку;
иначе — инициалы, рассчитанные утилитой getInitials(name). */}
<div className={s.avatar} title={user?.name || "Гость"}>
{user?.avatarUrl ? (
<img
src={user.avatarUrl}
alt=""
width={32}
height={32}
style={{ borderRadius: "999px" }}
/>
) : (
<span>{getInitials(user?.name)}</span>
)}
</div>
</div>
</div>
</header>
);
}Также добавим короткий реэкспорт, чтобы импортировать компонент и типы одной строкой:
export * from "./Header";
export * from "./header.types";И встроим его на обновлённую главную страницу приложения:
import { useState } from "react";
import { Header } from "@/components/Header";
import "./App.css";
function App() {
const [active, setActive] = useState("catalog");
return (
<>
{/* Передаём в шапку базовые параметры для её настройки */}
<Header
activeNavId={active}
onNavigate={setActive}
user={{ name: "Мария Петрова" }}
onBellClick={() => console.log("bell")}
/>
<main style={{ padding: 16 }}>Контент вкладки: {active}</main>
</>
);
}
export default App;Таким образом наш стартовый App.tsx становится корнем приложения, внутри которого живут компоненты, управлять которыми можно с использованием определённого нами API. В следующей лабораторной мы добавим к этой шапке таблицу аудиторий и подменим серверные данные на моки через MSW.