Итоговый обзор курса: повторяем всё

Поздравляю! 🎉 Ты прошел огромный путь, одолел все 27 уроков и теперь обладаешь серьезным багажом знаний. Это действительно крутой результат, и тебе точно есть чем гордиться! 💪

Теперь, когда у тебя за плечами есть практика, пришло время собрать весь этот пазл в единую и четкую систему. Это итоговое видео поможет тебе еще раз пройтись по всем ключевым точкам курса и окончательно закрепить материал.

---

Зачем тебе смотреть это видео снова?

- Сейчас, когда ты уже знаешь детали, этот обзор зазвучит для тебя совершенно иначе. Ты увидишь связи между темами, которые мог не заметить в начале. 🧠

- Повторение — это единственный способ перенести знания из краткосрочной памяти в реальный, устойчивый навык. Сделай так, чтобы твои усилия не пропали зря! ⚓️

- Уверенность: После этого повторения у тебя в голове сложится полная картина того, как работают все инструменты вместе.

---

Удели эти 4 часа финальному закреплению. Это твой фундамент для будущих проектов и уверенной работы. Ты большой молодец, давай подведем итоги красиво! 🏅🚀

Оценить качество материала и подачу материала автором видео:

Front-end

Трудоустройтесь middle front-end разработчиком на React JS (TypeScript) за 12-16 месяцев обучения с ежедневной менторской поддержкой в формате видео 1 на 1 и коммерческими проектами в портфолио

Перейти на курс
Front-end

Back-end

Трудоустройтесь middle back-end разработчиком за 12-16 месяцев обучения с ежедневной менторской поддержкой в формате видео 1 на 1 и коммерческими проектами в портфолио

Перейти на курс
Back-end

Карьерный бустер

Получите коммерческий опыт на реальных стартапах, прокачайте tech & soft навыки, научитесь работать в команде, проходить собеседования и получите первую работу в IT!

Перейти на курс
Карьерный бустер

Основы Front-end

Сделайте первый шаг в IT, освоив базовые знания разработки и научившись создавать небольшие проекты на JavaScript

Перейти на курс
Основы Front-end

Основы Back-end

Сделайте первый шаг в IT, освоив базовые знания разработки. Без опыта. Без математики. Только практика: JavaScript, SQL, Node JS, база данных

Перейти на курс
Основы Back-end

🚀 Итоговый обзор курса: повторяем всё

Автор конспекта: Stanislav

1. 🌐 Что такое Frontend и зачем нам React

1.1. 🎓 Что такое frontend разработка

Итак разберёмся что такое frontend приложение и как оно работает на основании этой диаграммы:

les28-1

  1. Пользователь открывает браузер и вводит к примеру в поисковую строку url на котором находится сайт.
  2. Браузер выполняет запрос за этими данными которые находятся на сервере.
  3. В ответ на запрос сервер отправляет необходимые файлы, а именно CSS, HTML и JavaScript, которые необходимы для того, чтобы отобразить сайт пользователю.
  4. Файлы есть, но чтобы пользователь увидел сам сайт браузер берёт эти файлы и выполняет работу с ними и отображает сайт, то есть запущенное фронтенд приложение.

📌 Frontend-приложение выполняется в браузере, а не на сервере.

Разберём простой пример с использованием чистого HTML

index.html
<!DOCTYPE html>
<html lang="en">
  <body>
    <h1>Musicfun Player</h1>
    <ul>
      <li>
        <div>Musicfun soundtrack</div>
        <audio
          controls
          src="https://musicfun.it-incubator.app/api/samurai-way-soundtrack.mp3"
        ></audio>
      </li>
      <li>
        <div>Musicfun soundtrack instrumental</div>
        <audio
          controls
          src="https://musicfun.it-incubator.app/api/samurai-way-soundtrack-instrumental.mp3"
        ></audio>
      </li>
    </ul>
  </body>
</html>

Чтобы запустить этот файл в VS Code вам необходимо использовать дополнительное расширение Live Server который запустит локальный сервер и вы сможете с помощью его просмотреть этот html файл на созданном порту к примеру http://127.0.0.1:5500/index.html который будет вам доступен локально.

Почему этот файл не является frontend приложением - потому что это просто файл который имеет статические данные. Да мы можем использовать умный html элемент который может выполнить такую функцию как проигрывание аудио, но это сложно назвать приложением так как больше чем отображение захардкоженных данных он больше ничего не умеет.

Так что же такое frontend разработка? Прежде чем мы определимся с этим понятием разберём еще одну диаграмму:

les28-2

  1. HTML страница это все лишь текст да там есть свои правила написания и использования элементом, но это лишь TEXT файл который браузер может использовать.
  2. Браузер загружает этот HTML документ и начинает его парсить.
  3. На основании парсинга браузер создаёт DOM дерево - совокупность JS объектов.
  4. Дальше браузер имея DOM на основании его может отрисовать UI - визуальную часть которую мы можем видеть.

И вот теперь мы как программисты можем манипулировать этим DOM, то есть JS объектами которые влияют на то что будет отображаться в браузере.

💡 Frontend — это управление DOM и его визуальным состоянием.

1.2. 🏗️ Работа с DOM c помощью JS

Мы можем изменять DOM динамически с помощью JS, браузер это наблюдает и выполняет перерисовку сайта. Закомментируем часть элементов внутри index.html, создадим div элемент c id="root" и подключим js нам для этого нужно создать файл front-end.js и подключить с помощью тега script.

index.html
<!DOCTYPE html>
<html lang="en">
  <body>
    <div id="root"></div>
 
    <!-- закоментированный код -->
    <!-- <ul> ... </ul> -->
 
    <script src="front-end.js"></script>
  </body>
</html>

Мы сейчас на нативном JS создадим наше фронтенд-приложение. Первое, что мы с вами должны запомнить на всю жизнь: у нас есть данные и есть алгоритм рендера этих данных. Данные первичны. От данных потом появляется разметка. Поэтому создадим объект с данными:

js
const tracks = [
  {
    title: "Musicfun soundtrack",
    url: "https://musicfun.it-incubator.app/api/samurai-way-soundtrack.mp3",
  },
  {
    title: "Musicfun soundtrack instrumental",
    url: "https://musicfun.it-incubator.app/api/samurai-way-soundtrack.mp3",
  },
  {
    title: "Musicfun soundtrack instrumental -d",
    url: "https://musicfun.it-incubator.app/api/samurai-way-soundtrack.mp3",
  },
  {
    title: "Musicfun soundtrack instrumental -d",
    url: "https://musicfun.it-incubator.app/api/samurai-way-soundtrack.mp3",
  },
]

Далее нам необходим алгоритм отрисовки этих данных и для этого нам сперва нужно создать DOM элементы которые будем отображать. Для этого мы используем API браузера.

js
//...
const headerEl = document.createElement("h1")
headerEl.append("Musicfun Player")

Далее нам нужно добавить этот элемент в DOM c помощью div с id="root" и добавить в него этот элемент.

js
//...
 
const rootEl = document.getElementById("root")
 
rootEl.append(headerEl)

Создадим треки на основе данных:

js
//...
 
const tracksEl = document.createElement("ul")
tracks.forEach((track) => {
  const trackEl = document.createElement("li")
  const trackTitleEl = document.createElement("div")
  trackTitleEl.append(track.title)
  trackEl.append(trackTitleEl)
  tracksEl.append(trackEl)
})
 
const rootEl = document.getElementById("root")
 
rootEl.append(headerEl)
rootEl.append(tracksEl)

Вручную писать код для манипуляции DOM-деревом сложно и неэффективно. Вместо того чтобы фокусироваться на разработке плеера, приходится писать большое количество вспомогательного кода. Чтобы избежать прямой работы с DOM, целесообразно использовать библиотеки и фреймворки, такие, как React, Angular или Vue, которые существенно упрощают эту задачу.

Допишем треки:

js
//...
 
tracks.forEach((track) => {
  const trackEl = document.createElement("li")
 
  const trackTitleEl = document.createElement("div")
  trackTitleEl.append(track.title)
  trackEl.append(trackTitleEl)
 
  const trackPlayerEl = document.createElement("audio")
  trackPlayerEl.src = track.url
  trackPlayerEl.controls = true
 
  trackEl.append(trackPlayerEl)
 
  tracksEl.append(trackEl)
})
 
//...

Итак рассмотрим что у нас получилось

les28-3

  1. У нас был HTML файл который пустой
  2. Браузер его загрузил, а показывать нечего
  3. DOM небольшой так как нечего визуализировать
  4. Соответственно пользователь ничего не видит

les28-4

  1. JS подгружается в браузер тоже как текст
  2. Браузер видит что это JS код так как подключен с помощью тега script и отдает на интерпретацию движку который есть в браузере, то есть парсится этот JS и выполняется
  3. На основании чего у нас создаются какие-то DOM узлы, которые создаются отдельно от главного DOM.
  4. Далее нужно эти элементы добавить в главный DOM и выполнится перерисовка и обновится UI.

Мы могли бы продолжить разработку плеера на чистом JavaScript: добавить обработчики событий, реализовать загрузку, создание и удаление треков, вручную управлять состоянием и выполнять запросы. Однако такой подход быстро усложняет код и делает разработку менее комфортной и более затратной по времени, так как большая часть усилий уходит на работу с DOM и вспомогательную логику. Чтобы упростить процесс разработки и сосредоточиться на более важных аспектах приложения, целесообразно использовать готовые фреймворки и библиотеки, которые решают эти задачи эффективнее и избавляют от необходимости реализовывать базовую инфраструктуру вручную.

1.3. 👨🏻‍🏫 Зачем нужен React

Рассмотрим пример с innerHtml:

main-inner-html.js
const tracks = [
  {
    title: "Musicfun soundtrack",
    url: "https://musicfun.it-incubator.app/api/samurai-way-soundtrack.mp3",
  },
  {
    title: "Musicfun soundtrack instrumental",
    url: "https://musicfun.it-incubator.app/api/samurai-way-soundtrack.mp3",
  },
  {
    title: "Musicfun soundtrack instrumental -d",
    url: "https://musicfun.it-incubator.app/api/samurai-way-soundtrack.mp3",
  },
  {
    title: "Musicfun soundtrack instrumental -d",
    url: "https://musicfun.it-incubator.app/api/samurai-way-soundtrack.mp3",
  },
]
 
const rootEl = document.getElementById("root")
 
let html = ""
html = `<h1>Musicfun Player</h1>
<ul>
${tracks.map((track) => {
  return `<li>
            <div>${track.title}</div>
            <audio controls src="${track.url}"></audio>
          </li>`
})}
</ul>`
 
rootEl.innerHTML = html

При использовании innerHTML мы напрямую не создаём DOM-узлы в JavaScript. В этом случае браузер самостоятельно парсит HTML-строку, создаёт соответствующие DOM-объекты и выполняет отрисовку. Таким образом, парсинг и создание DOM-дерева перекладываются на браузер, а контроль над отдельными элементами теряется. В небольших приложениях это некритично, однако в сложных фронтенд-приложениях с высокой интерактивностью важно иметь явные ссылки на создаваемые элементы для управления состоянием и обработкой событий.

Современные фреймворки, такие как React, решают эту проблему, предоставляя разработчику декларативный API в виде JSX — синтаксиса, похожего на HTML, но являющегося JavaScript. React самостоятельно создаёт и управляет DOM-узлами, не полагаясь на браузерный парсинг строк. JSX компилируется в обычный JavaScript, что позволяет легко работать с событиями, данными и логикой приложения. При изменении состояния React не перерисовывает весь DOM, а точечно обновляет только необходимые узлы, выступая прослойкой между бизнес-логикой и реальным DOM. В этом и заключается ключевая эффективность и практическая ценность React

2. 📚 TypeScript, NodeJS, Vite

2.1. 🎓 TypeScript зачем он нужен

В современной фронтенд-разработке TypeScript используется как надстройка над JavaScript — дополнительный слой, который помогает писать более надёжный, предсказуемый и поддерживаемый код. Он не заменяет JavaScript, а расширяет его возможностями статической типизации и инструментами для контроля структуры данных. На практике разработка крупных веб-приложений без TypeScript сегодня практически невозможна из-за сложности и масштаба таких систем.

les28-5

На этапе разработки (Compile Time) мы работаем с TypeScript и файлами.ts /.tsx (main.tsx, Playlist.tsx, index.ts). В этот момент TypeScript выполняет проверку типов, помогает определить структуру объектов и выявляет ошибки ещё до запуска приложения. JSX/TSX используется как удобный синтаксис для описания интерфейса внутри JavaScript-кода. После этого происходит компиляция (транспиляция): TypeScript-код преобразуется в обычный JavaScript.

На этапе выполнения (Run Time) браузер получает уже готовые JavaScript-файлы (main.js, Playlist.js, index.js) и выполняет их. Браузер не знает о TypeScript и типах — вся эта логика существует исключительно на этапе разработки и служит инструментом повышения качества и удобства разработки.

2.2. ⚙️ NodeJS

Хотя мы создаем приложения для браузера, нам необходима еще одна платформа — Node.js. Как и Google Chrome, Node.js построена на движке V8, поэтому она отлично «понимает» JavaScript.

Зачем фронтенд-разработчику Node.js? Наше итоговое приложение работает в браузере, но Node.js берёт на себя роль мощного инструмента для управления разработкой. Она помогает автоматизировать рутинные процессы и готовить код к продакшену.

les28-6

Основные задачи, которые решает Node.js в нашем проекте:

  • Управление зависимостями (Dependencies): Подгрузка внешних библиотек (например, React).
  • Транспиляция: Преобразование современного кода или TypeScript в чистый JavaScript, который поймет любой браузер.
  • Сборка (Bundling): Объединение множества файлов проекта в один или несколько оптимизированных бандлов.
  • Минификация: Сжатие кода для уменьшения размера файлов и ускорения загрузки сайта.
  • Tree Shaking: Удаление неиспользуемого кода из итоговой сборки.

2.3. 🦾 Vite: Автоматизация сборки и локальный сервер

Настройка всех инструментов сборки (транспиляции, минификации, бандлинга) вручную — задача крайне сложная. Чтобы не тратить на это время, существуют готовые решения. Одним из самых быстрых и популярных инструментов для React-разработки сегодня является Vite.

les28-7

Что делает Vite? Vite — это инструмент, который объединяет в себе все необходимые утилиты и позволяет запустить проект буквально одной командой.

Процесс работы выглядит так:

  1. Исходный код: Мы пишем код в удобном для нас формате: файлы.ts,.tsx (TypeScript), CSS-модули и HTML.
  2. Команда npm run dev: Когда мы запускаем эту команду, Vite берёт наши исходники и «на лету» транспилирует и собирает их.
  3. Результат: На выходе получаются стандартные файлы (JS, HTML, CSS), которые гарантированно понимает любой современный браузер.

Локальный сервер разработки Помимо сборки кода, Vite выполняет еще одну критически важную функцию — он запускает локальный веб-сервер.

  • localhost:5173: Vite выделяет специальный адрес (порт) на вашем компьютере.
  • Связь с браузером: Когда вы вводите этот адрес в браузере, тот отправляет запрос на сервер Vite.
  • Доставка кода: Сервер отдает собранные файлы в браузер, и ваше фронтенд-приложение «оживает».

Главный плюс: Вам не нужно быть экспертом по сборщикам. Достаточно установить Vite через NPM/NPX, выбрать нужные параметры при создании проекта, и всё будет работать «из коробки». Это значительно улучшает Developer Experience, позволяя сфокусироваться на написании кода, а не на конфигурационных файлах.

3. 🏗️ Создание приложения

Перед тем как создать приложение нужно чтобы был уставлен NodeJS который можно установить за этой ссылкой.

Далее создаем приложение используя Vite с помощью это команды через терминал:

bash
npm create vite@latest

Далее Vite задаст несколько вопросов:

  • Project name: Если вы хотите создать проект в текущей папке, введите точку (.).
  • Select a framework: Выберите React.
  • Select a variant: Выберите TypeScript. После этого Vite создаст структуру проекта.

После установки приложения нужно его открыть в VS Code или WebStorm и запустим приложение с помощью команды в терминале:

bash
npm run dev

Вы увидите ошибку в терминале так как нужно было прежде выполнить установку зависимостей и затем мы можем запустить приложение.

bash
npm instal
npm run dev

Команда dev — это псевдоним (скрипт), который описан в файле package.json в секции scripts. После запуска вы увидите в терминале адрес, по которому доступно ваше приложение, обычно http://localhost:5173/. Откройте его в браузере.

Разбор package.json Это главный файл проекта с точки зрения Node.js. В нём содержится ключевая информация:

  • scripts: команды для управления проектом (dev, build, lint).
  • dependencies: библиотеки, необходимые для работы приложения в браузере (например, react, react-dom).
  • devDependencies: библиотеки, нужные только для процесса разработки (например, typescript, vite).

3.1. 🔹 Анализ React приложения

Хотя детальное изучение структуры будет происходить на протяжении всего курса, сейчас важно зафиксировать ключевые элементы, которые превращают набор файлов в работающее приложение.

  1. Ключевые файлы проекта package.json:
  • Главный файл с точки зрения Node.js. Именно он превращает обычную папку в проект, позволяя запускать его с помощью модулей, пакетов и сборщика Vite.
  • index.html: Файл, который отправляется в браузер. Внутри находится практически пустой <body> с единственным <div id="root">. Это «контейнер», в который React будет динамически внедрять (рендерить) весь интерфейс. В теге <script> здесь указана точка входа — файл main.tsx.
  • src/main.tsx: «Сердце» запуска приложения; именно с этого файла React начинает свою работу.
  1. Конфигурационные файлы
  • tsconfig.json: Настройки TypeScript. Определяет правила компиляции и превращает проект в среду с типизацией.
  • vite.config.ts: Настройки сборщика Vite. Здесь подключаются плагины и описываются правила сборки проекта для разработки и продакшена.

4. 🧩 JSX и Компоненты

4.1. 🔹 Почему расширение.tsx, а не.ts?

Именно здесь начинается "магия" React. Расширение файла .tsx говорит нам о том, что внутри используется JSX.

  • JSX (JavaScript XML) — это синтаксис, который позволяет писать HTML-подобный код прямо внутри JavaScript.
  • Это не строка: Важно понимать, что мы не ставим кавычки вокруг тегов. Это живой код, "HTML внутри JS".
  • Транспиляция: Браузеры не понимают JSX. Инструменты (вроде Vite/Babel) превращают этот код в обычный JavaScript.
  • Пример: Код <App /> превращается в вызов функции jsx(...) или React.createElement(...). Вы можете проверить это в инструменте Babel JSX Online.

4.2. 🧱 Что такое компонент App?

Компонент — это фундаментальный строительный блок любого современного приложения (React, Vue, Angular).

  • Внешний вид: Для разработчика использование компонента выглядит как написание кастомного HTML-тега (например, <App />).
  • Техническая суть: На самом деле компонент — это JavaScript-функция, которая возвращает JSX-разметку.
  • Иерархия: Компонент App — это главный корневой блок, который собирает в себе все остальные части интерфейса (хедер, контент, футер и т.д.).

4.3. 🧱 Важные правила написания компонентов

Правило одного узла Каждый компонент должен возвращать строго один родительский узел.

  • Ошибка: Возвращать <h1> и <div> на одном уровне без обертки.
  • Решение: Обернуть их в общий родитель.

React Fragment Чтобы не создавать лишних <div> в HTML-структуре (DOM), мы используем Фрагменты.

  • Синтаксис: <>... </> (пустые угловые скобки).
  • Зачем: Они группируют элементы для React, но исчезают при отрисовке в браузере.

4.4. 🔹 Модульная система: Export и Import

Чтобы main.tsx мог использовать код из App.tsx, файлы должны обмениваться данными через систему модулей.

Экспорт по умолчанию (Default Export)

  • Код: export default App;
  • Особенность: При импорте можно дать компоненту любое имя.
  • Минус: Это часто приводит к путанице в больших проектах (непонятно, что именно мы импортируем), поэтому этот способ не рекомендуется.

Именованный экспорт (Named Export)

  • Код: export const App = () => {... }
  • Импорт: Обязательно используются фигурные скобки: import { App } from './App';.
  • Плюс: Имя при импорте строго совпадает с именем при экспорте. Это делает код предсказуемым и безопасным.

Важное примечание про index.html:
В HTML-файле скрипт подключается с атрибутом type="module". Это сообщает браузеру, что используется современный стандарт ES-Modules (ESM). Браузер получает одну точку входа (main.tsx) и сам распутывает клубок всех импортов.

5. 🧠 Управление состоянием с useState

Мы закрываем main.tsx и концентрируемся на компоненте App. Здесь мы встречаем useState — хук, который позволяет React хранить значение, изменяемое во времени.

  • Принцип работы: Мы делегируем React задачу хранить данные (например, число 0) в своей внутренней "ячейке памяти".
  • Интерфейс: Хук возвращает нам две вещи:
  1. Само значение (актуальные данные).
  2. Функцию-сеттер (инструмент для изменения этого значения).
App.tsx
//...
export function App() {
  console.log("App rendering")
  const [count, setCount] = useState(10)
 
  const handleClick = () => {
    setCount(count + 1)
  }
 
  return (
    <>
      <h1>Vite + React {count}</h1>
      <div className="card">
        <button onClick={handleClick}>count is {count}</button>
      </div>
    </>
  )
}

🔸 Реактивность и Декларативный подход

Главная идея: Разметка зависит от данных. Если данные меняются, компонент должен "выплюнуть" новую разметку.

Мы не говорим браузеру, как менять DOM (найди элемент, поменяй текст). Мы описываем, каким интерфейс должен быть в зависимости от текущего состояния (count). Это и есть декларативный подход.

React реактивно реагирует на вызов функции-сеттера: он понимает, что данные изменились, и запускает процесс обновления.

🔸 Жизненный цикл обновления (Render Cycle)

Давайте проследим, что происходит пошагово, когда мы кликаем на кнопку счетчика:

  1. Событие: Клик вызывает функцию-сеттер (setCount).
  2. Обновление данных: React обновляет значение в своей внутренней памяти.
  3. Триггер рендера: React понимает, что состояние изменилось, и повторно вызывает функцию компонента App.
  4. Новые данные: При новом вызове useState возвращает уже обновленное значение (например, 1 вместо 0).
  5. Генерация JSX: Компонент возвращает новую структуру JSX с актуальными цифрами.
  6. Commit фаза: React сравнивает новый JSX со старым, находит отличия (Diffing) и точечно обновляет реальный DOM браузера.

🧩 Синтаксис JSX: Фигурные скобки

Внутри JSX мы смешиваем HTML и JavaScript.

  • Без скобок: count — это просто текст (слово "count").
  • С фигурными скобками: {count} — это обращение к переменной JavaScript.
  • В пропсах: То же самое касается атрибутов, например onClick. Чтобы передать туда функцию, мы используем {... }.

🔸 Важно: Что такое "Рендеринг"?

В контексте React "Рендеринг" ≠ Рисование пикселей на экране.

  • Render (Рендеринг): Это просто вызов функции компонента. Компонент запускается, отрабатывает логику и возвращает JSX. Это происходит часто (например, при каждом клике).
  • Commit (Фиксация): Это момент, когда React берёт результат рендера и применяет его к реальному DOM-дереву.

Итог: useState — это наш инструмент управления бизнес-логикой (данными), а JSX — это рендер-алгоритм, который описывает вид приложения на основе этих данных.

6. 🧩 Переносим плеер в JSX

6.1. 🧩 HTML => JSX

Используем пример который в начале разбирали где мы писали простой HTML файл и скопируем затем вставим в новый App.tsx файл и увидим что все работает как будто это просто HTML но нет это JS.

App.tsx
import "./App.css"
 
export function App() {
  return (
    <>
      <h1>Musicfun Player</h1>
      <ul>
        <li>
          <div>Musicfun soundtrack</div>
          <audio
            controls
            src="https://musicfun.it-incubator.app/api/samurai-way-soundtrack.mp3"
          ></audio>
        </li>
        <li>
          <div>Musicfun soundtrack instrumental</div>
          <audio
            controls
            src="https://musicfun.it-incubator.app/api/samurai-way-soundtrack-instrumental.mp3"
          ></audio>
        </li>
      </ul>
    </>
  )
}

6.2. 🧩 JS => JSX

Итак перенесем часть JS и адаптируем в JS используя массив с треками и метод map который поможет нам создать треки.

App.tsx
import "./App.css"
 
const tracks = [
  {
    title: "Musicfun soundtrack",
    url: "https://musicfun.it-incubator.app/api/samurai-way-soundtrack.mp3",
  },
  {
    title: "Musicfun soundtrack instrumental",
    url: "https://musicfun.it-incubator.app/api/samurai-way-soundtrack.mp3",
  },
  {
    title: "Musicfun soundtrack instrumental",
    url: "https://musicfun.it-incubator.app/api/samurai-way-soundtrack.mp3",
  },
  {
    title: "Musicfun soundtrack instrumental",
    url: "https://musicfun.it-incubator.app/api/samurai-way-soundtrack.mp3",
  },
]
 
export function App() {
  return (
    <>
      <h1>Musicfun Player</h1>
      <ul>
        {tracks.map((track) => {
          return (
            <li>
              <div>{track.title}</div>
              <audio
                controls //controls={true}
                src={track.url}
              ></audio>
            </li>
          )
        })}
      </ul>
    </>
  )
}

Итак как сам React в данном примере не перерисовывает так как он получает статические данные в ввиде массива и нужно использовать инструменты React а именно useState в который мы передадим массив и будем уже иметь возможность с помощью React интерактивно взаимодействовать с этими данными

App.tsx
import { useState } from "react"
import "./App.css"
 
export function App() {
  const [tracks, setTracks] = useState([
    {
      title: "Musicfun soundtrack",
      url: "https://musicfun.it-incubator.app/api/samurai-way-soundtrack.mp3",
    },
    {
      title: "Musicfun soundtrack instrumental",
      url: "https://musicfun.it-incubator.app/api/samurai-way-soundtrack.mp3",
    },
    {
      title: "Musicfun soundtrack instrumental",
      url: "https://musicfun.it-incubator.app/api/samurai-way-soundtrack.mp3",
    },
    {
      title: "Musicfun soundtrack instrumental",
      url: "https://musicfun.it-incubator.app/api/samurai-way-soundtrack.mp3",
    },
  ])
 
  return (
    <>
      <h1>Musicfun Player</h1>
      <ul>
        {tracks.map((track) => {
          return (
            <li>
              <div>{track.title}</div>
              <audio
                controls //controls={true}
                src={track.url}
              ></audio>
            </li>
          )
        })}
      </ul>
    </>
  )
}

7. 📌 Render algorithm vs Data structure

У нас есть структура данных и есть алгоритм, который с этой структурой работает. Например, у нас есть массив треков — это структура данных. На основании этой структуры мы должны отрисовать список треков в интерфейсе.

les28-8

Также треки могут находится в разных состояниях, и на основании этих данных мы можем отображать разный результат, то есть описывать алгоритм работы с данной структурой. Например, если const tracks = [], мы отображаем сообщение о том, что треков нет. Если const tracks = null, отображаем состояние загрузки.

Допустим, мы хотим выделить трек и показать, что он выбран. В этом случае нужно задать вопрос: каким образом мы должны работать с данными, чтобы изменилась отрисовка UI. Один из вариантов — добавить в объект трека дополнительное поле, например selected: true. Изменяя значение этого поля, мы можем управлять отображением выбранного состояния в интерфейсе.

les28-9

Таким образом, одна из ключевых задач — правильно подбирать структуру данных, чтобы алгоритму было просто и эффективно выполнять свою работу, а UI — корректно реагировать на изменения состояния.

8. 🧠 TS типизация для useState + условный рендеринг

Если закомментировать треки, TypeScript будет показывать ошибку о том, что таких элементов не существует. Это происходит потому, что у нас отсутствует явная типизация данных. TypeScript, опираясь на значение, переданное в useState, предположил, что у трека есть поля title и url. Однако после удаления этих данных они больше не существуют, а типы по-прежнему не определены, из-за чего возникают ошибки.

Эти проблемы можно избежать, если заранее описать типы и явно указать их при работе с состоянием. Это делает поведение кода предсказуемым и защищает от подобных ошибок на этапе компиляции.

App.tsx
import { useState } from "react"
import "./App.css"
 
type Track = {
  id: number
  title: string
  url: string
}
 
export function App() {
  const [tracks, setTracks] = useState<Track[]>([
    // {
    //   id: 1,
    //   title: "Musicfun soundtrack",
    //   url: "https://musicfun.it-incubator.app/api/samurai-way-soundtrack.mp3",
    // },
    // {
    //   id: 2,
    //   title: "Musicfun soundtrack instrumental",
    //   url: "https://musicfun.it-incubator.app/api/samurai-way-soundtrack.mp3",
    // },
    // {
    //   id: 3,
    //   title: "Musicfun soundtrack instrumental",
    //   url: "https://musicfun.it-incubator.app/api/samurai-way-soundtrack.mp3",
    // },
    // {
    //   id: 4,
    //   title: "Musicfun soundtrack instrumental",
    //   url: "https://musicfun.it-incubator.app/api/samurai-way-soundtrack.mp3",
    // },
  ])
 
  return (
    <>
      <h1>Musicfun Player</h1>
      <ul>
        {tracks.map((track) => {
          return (
            <li>
              <div>{track.title}</div>
              <audio controls src={track.url}></audio>
            </li>
          )
        })}
      </ul>
    </>
  )
}

Мы описали useState таким образом, что он может хранить массив треков. Для этого мы использовали дженерик, с помощью которого явно указали тип данных состояния. В результате у нас исчезает ошибка типизации — точнее, пропадает ошибка о том, что поля title или url не существуют.

Подробнее работу с дженериками и типизацией useState вы можете изучить в курсе React: путь Самурая без альтернатив.

Далее можно переходить к обработке ситуации, когда у нас отсутствуют треки и необходимо корректно отобразить это состояние в интерфейсе.

App.tsx
//...
return (
  <>
    <h1>Musicfun Player</h1>
    {tracks.length === 0 && <span>No tracks</span>}
    <ul>
      {tracks.map((track) => {
        return (
          <li>
            <div>{track.title}</div>
            <audio controls src={track.url}></audio>
          </li>
        )
      })}
    </ul>
  </>
)

Также было отмечено, что состояние может быть не только массивом треков или пустым массивом, но и значением null. Поэтому мы расширяем типизацию и добавляем ещё одно допустимое значение — null.

Это позволяет корректно описать состояние загрузки и явно зафиксировать все возможные варианты данных, с которыми может работать компонент.

App.tsx
//...
const [tracks, setTracks] = useState<Track[] | null>(null)
 
return (
  <>
    <h1>Musicfun Player</h1>
    {tracks === null && <span>Loading...</span>}
    {tracks?.length === 0 && <span>No tracks</span>}
    <ul>
      {tracks?.map((track) => {
        return (
          <li>
            <div>{track.title}</div>
            <audio controls src={track.url}></audio>
          </li>
        )
      })}
    </ul>
  </>
)

Итак, мы описали алгоритм поведения и отображения разметки при разных состояниях useState.

  • Если состояние содержит пустой массив, мы отображаем span с текстом NO TRACKS.
  • Если useState равен null, мы отображаем состояние загрузки.
  • Если в состоянии присутствуют треки, мы отображаем список треков.

Таким образом, логика отображения напрямую зависит от состояния данных и остаётся простой, предсказуемой и легко расширяемой.

9. ⏱️ Hook useEffect

🔸 Сценарий: Загрузка приложения

Когда мы открываем приложение (например, "Music Fun Player"), первым делом мы видим заголовок и надпись "Loading...". Так устроено абсолютное большинство фронтенд-приложений:

  1. Мы показываем интерфейс-заглушку (лоадер).
  2. Данные запрашиваются с сервера.
  3. Пока идет запрос, пользователь видит индикатор загрузки.
App.tsx
import { useState, useEffect } from "react"
import "./App.css"
 
type Track = {
  id: number
  title: string
  url: string
}
 
export function App() {
  const [tracks, setTracks] = useState<Track[] | null>(null)
 
  useEffect(() => {
    setTimeout(() => {
      setTracks([
        {
          id: 1,
          title: "Musicfun soundtrack",
          url: "https://musicfun.it-incubator.app/api/samurai-way-soundtrack.mp3",
        },
        {
          id: 2,
          title: "Musicfun soundtrack instrumental",
          url: "https://musicfun.it-incubator.app/api/samurai-way-soundtrack.mp3",
        },
      ])
    }, 3000)
  }, [])
 
  return (
    <>
      <h1>Musicfun Player</h1>
      {tracks === null && <span>Loading...</span>}
      {tracks?.length === 0 && <span>No tracks</span>}
      <ul>
        {tracks?.map((track) => {
          return (
            <li>
              <div>{track.title}</div>
              <audio controls src={track.url}></audio>
            </li>
          )
        })}
      </ul>
    </>
  )
}

🌐 Где делать запрос на сервер?

Мы не имеем права выполнять запросы, запускать таймеры или работать с localStorage прямо внутри тела функции-компонента.

  • Правило: Компонент должен быть "чистой" функцией рендеринга. Его задача — просто вернуть JSX на основе входных данных.
  • Решение: Для выполнения "побочных эффектов" (Side Effects) существует второй главный хук — useEffect.

Синтаксис: Он принимает два аргумента: колбэк-функцию (сам эффект) и массив зависимостей.

  • Массив [] (пустой): Это инструкция для React: "Выполни этот код только один раз, когда компонент будет отрисован впервые".

🔸 Пошаговый разбор жизненного цикла

Давайте проследим хронологию событий при загрузке данных:

1. 🔸 Первый рендер (Render Phase)
  • useState инициализируется значением null.
  • React регистрирует useEffect, но пока не выполняет его.
  • Компонент возвращает JSX для состояния "Loading...".
2. 🔸 Фаза фиксации (Commit Phase)
  • React обновляет реальный DOM.
  • Пользователь видит на экране текст "Loading...".
3. 🔸 Выполнение эффекта (Passive Effects)
  • Сразу после того, как commit прошел, React запускает колбэк из useEffect.
  • Внутри происходит запрос к API. Когда данные получены, мы вызываем сеттер (setTracks).
4. 🔸 Второй рендер
  • Вызов сеттера сообщает Реакту, что данные изменились.
  • Компонент вызывается заново. Теперь useState возвращает массив треков.
  • useEffect на этот раз игнорируется, так как массив зависимостей [] пуст и не изменился.
5. 🔸 Финальный коммит
  • React сравнивает старый и новый JSX.
  • Реальный DOM обновляется, и пользователь видит список музыки.

🔸 Mount, Update, Unmount

Мы можем воспринимать жизнь компонента через три стадии:

  1. Mount (Вмонтирование): Компонент "рождается" и вставляется в DOM впервые. Этому соответствует useEffect с пустым массивом [].
  2. Update (Обновление): Компонент перерисовывается из-за изменения пропсов или стейта.
  3. Unmount (Размонтирование): Компонент удаляется со страницы (умирает).

10. 🌐 Про Back-end API, musicfun api, apihub, swagger

Работа современного фронтенд-приложения строится на постоянном диалоге с сервером. Когда пользователь открывает страницу, он сначала видит лишь оболочку (скелет приложения), в то время как за кулисами происходит сложный процесс получения данных.

🔸 Swagger: Как фронтенд понимает бэкенд

Для того чтобы этот диалог был успешным, разработчикам нужен «общий язык» или контракт. Эту роль выполняет Swagger-документация. Это не просто справочник, а детальное описание того, какие эндпоинты (адреса) нам доступны, какие параметры нужно передать и, самое главное, в каком виде придет ответ.

Для работы с реальными данными (например, в Music Fun API) используется персональный API Key. Это ваш пропуск в систему, который необходимо держать в секрете. В самом интерфейсе Swagger можно нажать кнопку Authorize, ввести свой ключ и протестировать запрос, чтобы заранее увидеть структуру данных, которые нам предстоит обработать.

les28-10

Все необходимые ссылки:

  1. API Hub
  2. MusicFun API - Swagger

11. 📌 Fetch + TS

Основной инструмент для получения данных — встроенная функция fetch. Однако ее нельзя вызывать просто так внутри компонента, иначе запрос будет улетать при каждой перерисовке. Поэтому мы оборачиваем его в хук useEffect.

App.tsx
import { useState, useEffect } from "react"
import "./App.css"
 
type Track = {
  id: number
  title: string
  url: string
}
 
export function App() {
  const [tracks, setTracks] = useState<Track[] | null>(null)
 
  useEffect(() => {
    setTimeout(() => {
      fetch("https://musicfun.it-incubator.app/api/1.0/playlists/tracks", {
        headers: {
          "api-key": "XXX",
        },
      })
        .then((res) => res.json())
        .then((json) => setTracks(json.data))
    }, 3000)
  }, [])
 
  return (
    <>
      <h1>Musicfun Player</h1>
      {tracks === null && <span>Loading...</span>}
      {tracks?.length === 0 && <span>No tracks</span>}
      <ul>
        {tracks?.map((track) => {
          return (
            <li>
              <div>{track.title}</div>
              <audio controls src={track.url}></audio>
            </li>
          )
        })}
      </ul>
    </>
  )
}

Логика процесса выглядит следующим образом: мы вызываем fetch, указываем URL и передаем настройки авторизации в заголовках (headers). Получив «сырой» ответ, мы сначала превращаем его в JSON, а затем извлекаем полезную нагрузку (например, json.data) и сохраняем её в локальный стейт через функцию-сеттер (например, setTracks)

🟦 Роль TypeScript в работе с данными

Чтобы приложение было стабильным, нам нельзя доверяться серверу «на слово». Мы должны точно описать структуру ожидаемых данных с помощью типов TypeScript. Это создаёт защитный барьер: если структура API изменится или мы совершим опечатку в названии поля (например, напишем titile вместо title), TypeScript сразу подсветит это как ошибку.

Мы создаем иерархию типов, которая в точности повторяет вложенность объектов из Swagger:

  • Сначала описываем мелкие детали (например, Attachment с его URL).
  • Затем описываем атрибуты трека (TrackAttributes), куда входят заголовки и массив вложений.
  • И, наконец, объединяем всё в тип Track, где есть уникальный ID и атрибуты.
App.tsx
import { useState, useEffect } from "react"
import "./App.css"
 
type Attachment = {
  url: string
}
 
type TrackAttributes = {
  title: string
  attachments: Attachment[]
}
 
type Track = {
  id: number
  attributes: TrackAttributes
}
 
export function App() {
  const [tracks, setTracks] = useState<Track[] | null>(null)
 
  useEffect(() => {
    fetch("https://musicfun.it-incubator.app/api/1.0/playlists/tracks", {
      headers: {
        "api-key": "XXX",
      },
    })
      .then((res) => res.json())
      .then((json) => setTracks(json.data))
  }, [])
 
  return (
    <>
      <h1>Musicfun Player</h1>
      {tracks === null && <span>Loading...</span>}
      {tracks?.length === 0 && <span>No tracks</span>}
      <ul>
        {tracks?.map((track) => {
          return (
            <li>
              <div>{track.attributes.title}</div>
              <audio controls src={track.attributes.attachments[0].url}></audio>
            </li>
          )
        })}
      </ul>
    </>
  )
}

12. 📌 Как работает React, FiberNode (теория)

12.1. ⚙️ Lifecycle Mount Mode

les11-12

Разберём жизненный цикл компоненты App при монтировании:

  1. React создаёт для компоненты объект FiberNode, который будет хранить "состояния" этой компоненты. То есть React готовит место для хранения всех необходимых данных компоненты.
  2. Затем React вызывает функцию-компоненту, начинается render phase.
  3. Происходит инициализация начальных состояний, например, которые мы определили с помощью useState.
  4. Фиксируются действия, которые нужно будет выполнить позже, например, какая функция из useEffect должна быть вызвана.
  5. Компонента возвращает JSX — объект ReactElement, который может содержать другие объекты. Этот объект возвращается в React.
  6. Затем React переходит к следующей фазе — commit phase, когда на основе полученной информации создаётся DOM.
  7. Можно увидеть визуальный результат на странице.
  8. После визуализации React приступает к выполнению зарегистрированных ранее в очередь функций. В нашем примере был зарегистрирован useEffect, который теперь выполняется.
  9. Внутри useEffect мы делаем запрос к серверу через fetch и получаем ответ.
  10. После получения ответа мы обновляем состояние с помощью setTracks(serverTracks), фиксируя новое значение в FiberNode.

12.2. 🔃 Lifecycle Update Mode

les11-13

Итак, мы зафиксировали новое значение, и tracks изменились. React видит, что данные, влияющие на отрисовку, изменились, поэтому нужно создать новый результат на основе новых данных, и React переходит к этапу обновления:

  1. React повторно вызывает компоненту App, чтобы обновить DOM, который видит пользователь. На этот раз useEffect не будет вызван, так как он срабатывает только один раз при монтировании.
  2. После этого React снова переходит в render phase, где формируется новый обновлённый ReactElement.
  3. Затем начинается commit phase, и React обновляет DOM на основе новой информации.
  4. Теперь можно увидеть визуальный результат после обновления.

13. 📌 Логика выделения трека, onClick

Для реализации выделения конкретного трека (например, добавления рамки при клике) нам требуется новое состояние. Мы создаем «ячейку памяти», где будем хранить идентификатор (ID) выбранного элемента.

Так как при старте приложения ничего не выбрано, начальным значением будет null. Мы используем TypeScript, чтобы разрешить хранить в этой переменной либо строку, либо ничего:

App.tsx
//...
const [selectedTrackId, setSelectedTrackId] = useState<string | null>(null)
//...

🎨 Динамическая стилизация вибранного трека

Когда мы превращаем массив данных в список элементов с помощью метода .map(), мы можем внедрить логику проверки для каждого отдельного пункта.

Внутри итерации мы сравниваем: «Является ли ID текущего трека тем самым ID, который лежит в selectedTrackId?».

  • Если да — мы подготавливаем объект стилей с рамкой: style={{ border: '1px solid orange' }}.
  • Если нет — стили остаются пустыми.

Чтобы это заработало программно, мы вешаем обработчик onClick на элемент. При клике вызывается функция-сеттер setSelectedTrackId(track.id), которая меняет состояние. React видит это изменение, перерисовывает компонент, и оранжевая рамка «перепрыгивает» на нужный элемент.

App.tsx
import { useState, useEffect, CSSProperties } from "react"
import "./App.css"
 
type Attachment = {
  url: string
}
 
type TrackAttributes = {
  title: string
  attachments: Attachment[]
}
 
type Track = {
  id: number
  attributes: TrackAttributes
}
 
export function App() {
  const [tracks, setTracks] = useState<Track[] | null>(null)
  const [selectedTrackId, setSelectedTrackId] = useState<string | null>(null)
 
  useEffect(() => {
    fetch("https://musicfun.it-incubator.app/api/1.0/playlists/tracks", {
      headers: {
        "api-key": "XXX",
      },
    })
      .then((res) => res.json())
      .then((json) => setTracks(json.data))
  }, [])
 
  return (
    <>
      <h1>Musicfun Player</h1>
      {tracks === null && <span>Loading...</span>}
      {tracks?.length === 0 && <span>No tracks</span>}
      <ul>
        {tracks?.map((track) => {
          const style: CSSProperties = {}
 
          if (track.id === selectedTrackId) {
            style.border = "1px solid orange"
          }
          return (
            <li key={track.id} style={style} onClick={() => setSelectedTrackId(track.id)}>
              <div>{track.attributes.title}</div>
              <audio controls src={track.attributes.attachments[0].url}></audio>
            </li>
          )
        })}
      </ul>
    </>
  )
}

Почему важны ключи (key prop)

При рендеринге списков React требует, чтобы у каждого корневого элемента внутри .map() был уникальный атрибут key. Чаще всего для этого используется ID из базы данных.

Зачем это нужно React?

  • Идентификация: Без ключей React не понимает, какой элемент списка изменился, а какой остался прежним. Ему пришлось бы перерисовывать весь список целиком при любом обновлении данных.
  • Производительность: С ключом key={track.id} React получает «паспорт» элемента. При обновлении он видит: «Так, трек №105 остался на месте, у него просто сменился цвет рамки. Обновлю-ка я только этот маленький кусочек DOM».
  • Стабильность: Ключи предотвращают ошибки с состоянием (например, когда вы ввели текст в поле ввода в одном элементе списка, а при добавлении нового элемента этот текст «уплыл» к соседнему компоненту).

14. 📌 Реализация List–Details pattern

list-detail

Исходные данные

  • tracks (массив объектов) - Хранит список всех треков для отображения.
  • selectedTrackId (идентификатор выбранного трека, который может быть null или фактическим ID) - Хранит ID выбранного трека. Если null, ни один трек не выбран.

render-state

Задача

При клике на трек в списке List, необходимо выделить его (например, рамкой) и показать информацию о нем в блоке Detail.

selectedTrack

Далее реализуется паттерн "List–Detail". Помимо хранения selectedTrackId целесообразно иметь отдельную ячейку состояния для подгруженных деталей выбранного трека: selectedTrack, которая может быть объектом с деталями или null.

В момент клика по треку, помимо установки selectedTrackId, выполняется запрос к эндпоинту /tracks/{track.id} за деталями. До получения ответа можно сбросить selectedTrack в null, чтобы показать индикацию загрузки. После получения ответа данные сохраняются в selectedTrack.

App.tsx
import { useState, useEffect, CSSProperties } from "react"
import "./App.css"
 
type Attachment = {
  url: string
}
 
type TrackAttributes = {
  title: string
  attachments: Attachment[]
}
 
type Track = {
  id: number
  attributes: TrackAttributes
}
 
type TrackDetailsAttributes = {
  title: string
  lyrics: string
  attachments: Attachment[]
}
 
type TrackDetailsResourse = {
  id: string
  attributes: TrackDetailsAttributes
}
 
export function App() {
  const [tracks, setTracks] = useState<Track[] | null>(null)
  const [selectedTrackId, setSelectedTrackId] = useState<string | null>(null)
  const [selectedTrack, setSelectedTrack] = useState<TrackDetailsResourse | null>(null)
 
  useEffect(() => {
    fetch("https://musicfun.it-incubator.app/api/1.0/playlists/tracks?pageSize=5", {
      headers: {
        "api-key": "XXX",
      },
    })
      .then((res) => res.json())
      .then((json) => setTracks(json.data))
  }, [])
 
  const handleSelectTrack = (trackId: string) => {
    setSelectedTrackId(trackId)
 
    fetch("https://musicfun.it-incubator.app/api/1.0/playlists/tracks/" + selectedTrackId, {
      headers: {
        "api-key": "XXX",
      },
    })
      .then((res) => res.json())
      .then((json) => setSelectedTrack(json.data))
  }
 
  return (
    <>
      <h1>Musicfun Player</h1>
      {tracks === null && <span>Loading...</span>}
      {tracks?.length === 0 && <span>No tracks</span>}
      <ul>
        {tracks?.map((track) => {
          const style: CSSProperties = {}
 
          if (track.id === selectedTrackId) {
            style.border = "1px solid orange"
          }
          return (
            <li key={track.id} style={style} onClick={() => handleSelectTrack(track.id)}>
              <div>{track.attributes.title}</div>
              <audio controls src={track.attributes.attachments[0].url}></audio>
            </li>
          )
        })}
      </ul>
      <hr />
      <h2>Track Details</h2>
      {!selectedTrackId && <span>No selected track</span>}
      {selectedTrackId && !selectedTrack && <span>Loading...</span>}
      {selectedTrack && (
        <div key={}>
          <h4>{selectedTrack.attributes.title}</h4>
          <p>{selectedTrack.attributes.lyrics}</p>
        </div>
      )}
      {selectedTrack && selectedTrack.id !== selectedTrackId && <span>Loading...</span>}
    </>
  )
}

Условный рендеринг (List–Detail):

  • Если selectedTrackId нет — показываем "Трек не выбран".
  • Если selectedTrackId есть, но selectedTrack ещё null — показываем "Loading...".
  • Если selectedTrack присутствует — показываем детали: заголовок и текст песни.

15. 🧱 Что такое компонент? Декомпозиция приложения

Компонент — это строительный блок, который может состоять из нескольких мелких компонентов. Такой подход позволяет превратить сложный и объёмный фрагмент кода в один цельный блок.

Зачем это нужно?

Во-первых, чтобы разработчику было проще ориентироваться в большом объёме кода. Мы можем сгруппировать связанные между собой строки, функции, данные и логику в единый блок — там, где есть high cohesion (высокая связанность).

Во-вторых, компонент получает имя, отражающее его назначение. Например:

  • список треков — TrackList;
  • карточка трека — TrackDetail;
  • шапка сайта — Header.

На странице может быть несколько таких фрагментов, каждый из которых удобно выделить в отдельный компонент.

Наконец, компоненты снижают ментальную нагрузку и дают возможность многократного использования. В них может быть заложена определённая функциональность, которую легко применять повторно в разных местах проекта.

15.1. 🔹 React Component

🔗

React Component — это функция, имя которая начинается с заглавной буквы и которая возвращает какой либо JSX.

К примеру, у нас есть компонент App, который мы импортируем в главный файл main.tsx. Внутри App инкапсулирована (спрятана) логика и структура сайта: например, отображение списка треков или выбор конкретного трека.

App.tsx
import { useEffect, useState } from "react"
 
export function App() {
  //...
}

Файл main.tsx ничего об этом не знает. Его задача — запустить процесс: отрендерить компонент App, в котором уже заложены функциональность и структура JSX-тегов для отображения на странице.

main.tsx
import { createRoot } from "react-dom/client"
import "./index.css"
import { App } from "./App.tsx"
 
const rootEl = document.getElementById("root")
const reactRoot = createRoot(rootEl!)
reactRoot.render(<App />)

15.2. 🧱 Создаем новый компонент

Итак создадим новый компонент MainPage прям в файле main.tsx вместо компоненты App.

main.tsx
import { createRoot } from "react-dom/client"
import "./index.css"
 
const rootEl = document.getElementById("root")
const reactRoot = createRoot(rootEl!)
reactRoot.render(<MainPage />)
 
function MainPage() {
  return <div>Main page</div>
}

15.3. 🔹 Создаем структуру приложения

Мы хотим создать понятную структуру сайта, поэтому разделим его на отдельные блоки (компоненты). Это упрощает восприятие кода и позволяет легко понимать, какой элемент за что отвечает.

les28-11

main.tsx
import { createRoot } from "react-dom/client"
import "./index.css"
 
const rootEl = document.getElementById("root")
const reactRoot = createRoot(rootEl!)
reactRoot.render(<MainPage />)
 
function MainPage() {
  return (
    <div>
      <PageTitle />
      <Playlist />
      <TrackDetails />
    </div>
  )
}
 
function PageTitle() {
  return <div>PageTitle</div>
}
function Playlist() {
  return <div>Playlist</div>
}
function TrackDetails() {
  return <div>TrackDetails</div>
}

Итак перенесем код с App.tsx в созданные компоненты.

types.ts
export type Attachment = {
  url: string
}
 
export type TrackAttributes = {
  title: string
  attachments: Attachment[]
}
 
export type Track = {
  id: number
  attributes: TrackAttributes
}
 
export type TrackDetailsAttributes = {
  title: string
  lyrics: string
  attachments: Attachment[]
}
 
export type TrackDetailsResourse = {
  id: string
  attributes: TrackDetailsAttributes
}
main.tsx
import { useState, useEffect, CSSProperties } from "react"
import { createRoot } from "react-dom/client"
import { Track, TrackDetailsResourse } from "./types.ts"
import "./index.css"
 
const rootEl = document.getElementById("root")
const reactRoot = createRoot(rootEl!)
reactRoot.render(<MainPage />)
 
function MainPage() {
  return (
    <div>
      <PageTitle />
      <Playlist />
      <TrackDetails />
    </div>
  )
}
 
function PageTitle() {
  return <h1>Musicfun Player</h1>
}
 
function Playlist() {
  const [tracks, setTracks] = useState<Track[] | null>(null)
  const [selectedTrackId, setSelectedTrackId] = useState<string | null>(null)
  const [selectedTrack, setSelectedTrack] = useState<TrackDetailsResourse | null>(null)
 
  useEffect(() => {
    fetch("https://musicfun.it-incubator.app/api/1.0/playlists/tracks?pageSize=5", {
      headers: {
        "api-key": "XXX",
      },
    })
      .then((res) => res.json())
      .then((json) => setTracks(json.data))
  }, [])
 
  const handleSelectTrack = (trackId: string) => {
    setSelectedTrackId(trackId)
 
    fetch("https://musicfun.it-incubator.app/api/1.0/playlists/tracks/" + selectedTrackId, {
      headers: {
        "api-key": "XXX",
      },
    })
      .then((res) => res.json())
      .then((json) => setSelectedTrack(json.data))
  }
  return (
    <div>
      {tracks === null && <span>Loading...</span>}
      {tracks?.length === 0 && <span>No tracks</span>}
      <ul>
        {tracks?.map((track) => {
          const style: CSSProperties = {}
 
          if (track.id === selectedTrackId) {
            style.border = "1px solid orange"
          }
          return (
            <li key={track.id} style={style} onClick={() => handleSelectTrack(track.id)}>
              <div>{track.attributes.title}</div>
              <audio controls src={track.attributes.attachments[0].url}></audio>
            </li>
          )
        })}
      </ul>
    </div>
  )
}
 
function TrackDetails() {
  const [_, setTracks] = useState<Track[] | null>(null)
  const [selectedTrackId, setSelectedTrackId] = useState<string | null>(null)
  const [selectedTrack, setSelectedTrack] = useState<TrackDetailsResourse | null>(null)
 
  useEffect(() => {
    fetch("https://musicfun.it-incubator.app/api/1.0/playlists/tracks?pageSize=5", {
      headers: {
        "api-key": "XXX",
      },
    })
      .then((res) => res.json())
      .then((json) => setTracks(json.data))
  }, [])
 
  const handleSelectTrack = (trackId: string) => {
    setSelectedTrackId(trackId)
 
    fetch("https://musicfun.it-incubator.app/api/1.0/playlists/tracks/" + selectedTrackId, {
      headers: {
        "api-key": "XXX",
      },
    })
      .then((res) => res.json())
      .then((json) => setSelectedTrack(json.data))
  }
  return (
    <div>
      <h2>Track Details</h2>
      {!selectedTrackId && <span>No selected track</span>}
      {selectedTrackId && !selectedTrack && <span>Loading...</span>}
      {selectedTrack && (
        <div key={}>
          <h4>{selectedTrack.attributes.title}</h4>
          <p>{selectedTrack.attributes.lyrics}</p>
        </div>
      )}
      {selectedTrack && selectedTrack.id !== selectedTrackId && <span>Loading...</span>}
    </div>
  )
}

Создадим для каждой компоненты отдельно файл

PageTitle.tsx
export function PageTitle() {
  return <h1>Musicfun Player</h1>
}
Playlist.tsx
import { useState, useEffect, CSSProperties } from "react"
import { Track, TrackDetailsResourse } from "../types.ts"
 
export function Playlist() {
  const [tracks, setTracks] = useState<Track[] | null>(null)
  const [selectedTrackId, setSelectedTrackId] = useState<string | null>(null)
  const [selectedTrack, setSelectedTrack] = useState<TrackDetailsResourse | null>(null)
 
  useEffect(() => {
    fetch("https://musicfun.it-incubator.app/api/1.0/playlists/tracks?pageSize=5", {
      headers: {
        "api-key": "XXX",
      },
    })
      .then((res) => res.json())
      .then((json) => setTracks(json.data))
  }, [])
 
  const handleSelectTrack = (trackId: string) => {
    setSelectedTrackId(trackId)
 
    fetch("https://musicfun.it-incubator.app/api/1.0/playlists/tracks/" + selectedTrackId, {
      headers: {
        "api-key": "XXX",
      },
    })
      .then((res) => res.json())
      .then((json) => setSelectedTrack(json.data))
  }
  return (
    <div>
      {tracks === null && <span>Loading...</span>}
      {tracks?.length === 0 && <span>No tracks</span>}
      <ul>
        {tracks?.map((track) => {
          const style: CSSProperties = {}
 
          if (track.id === selectedTrackId) {
            style.border = "1px solid orange"
          }
          return (
            <li key={track.id} style={style} onClick={() => handleSelectTrack(track.id)}>
              <div>{track.attributes.title}</div>
              <audio controls src={track.attributes.attachments[0].url}></audio>
            </li>
          )
        })}
      </ul>
    </div>
  )
}
TrackDetails.tsx
import { useState, useEffect } from "react"
import { Track, TrackDetailsResourse } from "../types.ts"
 
export function TrackDetails() {
  const [_, setTracks] = useState<Track[] | null>(null)
  const [selectedTrackId, setSelectedTrackId] = useState<string | null>(null)
  const [selectedTrack, setSelectedTrack] = useState<TrackDetailsResourse | null>(null)
 
  useEffect(() => {
    fetch("https://musicfun.it-incubator.app/api/1.0/playlists/tracks?pageSize=5", {
      headers: {
        "api-key": "XXX",
      },
    })
      .then((res) => res.json())
      .then((json) => setTracks(json.data))
  }, [])
 
  const handleSelectTrack = (trackId: string) => {
    setSelectedTrackId(trackId)
 
    fetch("https://musicfun.it-incubator.app/api/1.0/playlists/tracks/" + selectedTrackId, {
      headers: {
        "api-key": "XXX",
      },
    })
      .then((res) => res.json())
      .then((json) => setSelectedTrack(json.data))
  }
  return (
    <div>
      <h2>Track Details</h2>
      {!selectedTrackId && <span>No selected track</span>}
      {selectedTrackId && !selectedTrack && <span>Loading...</span>}
      {selectedTrack && (
        <div key={}>
          <h4>{selectedTrack.attributes.title}</h4>
          <p>{selectedTrack.attributes.lyrics}</p>
        </div>
      )}
      {selectedTrack && selectedTrack.id !== selectedTrackId && <span>Loading...</span>}
    </div>
  )
}
MainPage.tsx
import { PageTitle } from "./PageTitle.tsx"
import { Playlist } from "./Playlist.tsx"
import { TrackDetails } from "./TrackDetails.tsx"
 
export function MainPage() {
  return (
    <div>
      <PageTitle />
      <Playlist />
      <TrackDetails />
    </div>
  )
}
main.tsx
import { MainPage } from "./MainPage/MainPage.tsx"
import { createRoot } from "react-dom/client"
import "./index.css"
 
const rootEl = document.getElementById("root")
const reactRoot = createRoot(rootEl!)
reactRoot.render(<MainPage />)

16. 📌 Поднятие стейта, props

На данном этапе компоненты не могут напрямую взаимодействовать друг с другом. Каждая компонента имеет собственное состояние и не зависит от остальных. Однако мы можем вынести часть данных на уровень выше — в общий родительский компонент. Такой подход называется поднятием состояния (lifting state up). В этом случае данные хранятся в родительской компоненте и передаются дочерним компонентам через свойства.

Компонента, которая владеет данными, управляет их изменением и передаёт их дочерним компонентам, является информационным экспертом. Такую компоненту принято называть контейнерной компонентой.

les28-12

16.1. 🧱 Добавляем props в компоненты

Props (сокращение от properties — «свойства») — это механизм для передачи данных от родительского компонента к дочернему в React и других подобных фреймворках (Vue, Svelte и т.д.).

PageTitle.tsx
type Props = {
  value: string
}
 
export function PageTitle(props: Props) {
  return <h1>{props.value}</h1>
}
TrackDetails.tsx
import { useState, useEffect } from "react"
import { TrackDetailsResourse } from "../types.ts"
 
type Props = {
  selectedTrackId: string | null
}
 
export function TrackDetails({ selectedTrackId }: Props) {
  const [selectedTrack, setSelectedTrack] = useState<TrackDetailsResourse | null>(null)
 
  useEffect(() => {
    if (!selectedTrackId) return
 
    fetch("https://musicfun.it-incubator.app/api/1.0/playlists/tracks/" + selectedTrackId, {
      headers: {
        "api-key": "XXX",
      },
    })
      .then((res) => res.json())
      .then((json) => setSelectedTrack(json.data))
  }, [])
 
  return (
    <div>
      <h2>Track Details</h2>
      {!selectedTrackId && <span>No selected track</span>}
      {selectedTrackId && !selectedTrack && <span>Loading...</span>}
      {selectedTrack && (
        <div key={}>
          <h4>{selectedTrack.attributes.title}</h4>
          <p>{selectedTrack.attributes.lyrics}</p>
        </div>
      )}
      {selectedTrack && selectedTrack.id !== selectedTrackId && <span>Loading...</span>}
    </div>
  )
}
MainPage.tsx
import { PageTitle } from "./PageTitle.tsx"
import { Playlist } from "./Playlist.tsx"
import { TrackDetails } from "./TrackDetails.tsx"
 
export function MainPage() {
  return (
    <div>
      <PageTitle value={"Musicfun Player"} />
      <Playlist />
      <TrackDetails selectedTrackId={null} />
    </div>
  )
}

17. ⏱️ Deps для useEffect, Inversion of control, callback

Когда мы передаём пустой массив зависимостей в useEffect, эффект выполняется только один раз — при монтировании компоненты.

При этом сама компонента может перерисовываться в двух основных случаях: если изменяется её локальное состояние или если меняются пропсы, передаваемые от родительской компоненты.

React отслеживает эти изменения и, на основе обновлённых данных, выполняет повторный рендер, в результате чего пользовательский интерфейс отображает актуальное состояние данных.

TrackDetails.tsx
import { useState, useEffect } from "react"
import { TrackDetailsResourse } from "../types.ts"
 
type Props = {
  selectedTrackId: string | null
}
 
export function TrackDetails(props: Props) {
  const [selectedTrack, setSelectedTrack] = useState<TrackDetailsResourse | null>(null)
 
  useEffect(() => {
    if (!props.selectedTrackId) return
 
    fetch("https://musicfun.it-incubator.app/api/1.0/playlists/tracks/" + props.selectedTrackId, {
      headers: {
        "api-key": "XXX",
      },
    })
      .then((res) => res.json())
      .then((json) => setSelectedTrack(json.data))
  }, [props.selectedTrackId])
 
  return (
    <div>
      <h2>Track Details</h2>
      {!props.selectedTrackId && <span>No selected track</span>}
      {props.selectedTrackId && !selectedTrack && <span>Loading...</span>}
      {selectedTrack && (
        <div key={}>
          <h4>{selectedTrack.attributes.title}</h4>
          <p>{selectedTrack.attributes.lyrics}</p>
        </div>
      )}
      {selectedTrack && selectedTrack.id !== props.selectedTrackId && <span>Loading...</span>}
    </div>
  )
}

Когда родительская компонента передаёт новые пропсы, например selectedTrackId, их значение может изменяться. Однако если UI напрямую не зависит от этого пропса, повторный рендер сам по себе не приведёт к выполнению побочных эффектов.

В результате при изменении selectedTrackId useEffect выполняется повторно, и запрашиваются новые актуальные данные для выбранного текущего трека.

Итак, выносим состояние selectedTrackId в MainPage — переносим useState на уровень основной компоненты. Далее через props передаём в компонент Playlist функцию изменения состояния (setter). С помощью компоненты Playlist мы будем изменять текущее значение selectedTrackId.

MainPage.tsx
import { PageTitle } from "./PageTitle.tsx"
import { Playlist } from "./Playlist.tsx"
import { TrackDetails } from "./TrackDetails.tsx"
import { useState } from "react"
 
export function MainPage() {
  const [selectedTrackId, setSelectedTrackId] = useState<string | null>(null)
 
  const handleTrackSelect = (trackId: string) => {
    setSelectedTrackId(trackId)
  }
 
  return (
    <div>
      <PageTitle value={"Musicfun Player"} />
      <Playlist selectedTrackId={selectedTrackId} onTrackSelect={handleTrackSelect} />
      <hr />
      <TrackDetails selectedTrackId={selectedTrackId} />
    </div>
  )
}
Playlist.tsx
import { useState, useEffect, CSSProperties } from "react"
import { Track, TrackDetailsResourse } from "../types.ts"
 
type Props = {
  selectedTrackId: string | null
  onTrackSelect: (value: string) => void
}
 
export function Playlist(props: Props) {
  const [tracks, setTracks] = useState<Track[] | null>(null)
  const [_, setSelectedTrack] = useState<TrackDetailsResourse | null>(null)
 
  useEffect(() => {
    fetch("https://musicfun.it-incubator.app/api/1.0/playlists/tracks?pageSize=5", {
      headers: {
        "api-key": "XXX",
      },
    })
      .then((res) => res.json())
      .then((json) => setTracks(json.data))
  }, [])
 
  const handleSelectTrack = (trackId: string) => {
    props.onTrackSelect(trackId)
 
    fetch("https://musicfun.it-incubator.app/api/1.0/playlists/tracks/" + props.selectedTrackId, {
      headers: {
        "api-key": "XXX",
      },
    })
      .then((res) => res.json())
      .then((json) => setSelectedTrack(json.data))
  }
  return (
    <div>
      {tracks === null && <span>Loading...</span>}
      {tracks?.length === 0 && <span>No tracks</span>}
      <ul>
        {tracks?.map((track) => {
          const style: CSSProperties = {}
 
          if (track.id === props.selectedTrackId) {
            style.border = "1px solid orange"
          }
          return (
            <li key={track.id} style={style} onClick={() => handleSelectTrack(track.id)}>
              <div>{track.attributes.title}</div>
              <audio controls src={track.attributes.attachments[0].url}></audio>
            </li>
          )
        })}
      </ul>
    </div>
  )
}
  • Данные передаются в компоненты Playlist и TrackDetails через props, чтобы компонент понимал, что именно ему нужно отображать.
  • В компонент Playlist передаётся функция выбора трека (onTrackSelect).
  • Когда пользователь выбирает трек, компонент вызывает onTrackSelect и передаёт выбранный трек наверх, где он сохраняется и используется дальше.

18. 📌 Анализ перерисовок

  • В каждый компонент добавляем console.log, чтобы увидеть, когда и почему он перерисовывается.
  • Дальше меняем состояние (state) и смотрим в консоли:
  • какие компоненты перерисовались;
  • в каком порядке;
  • что именно стало причиной: изменение state или изменение props.
  • Цель — понять, какие перерисовки возникают при изменении состояния, и отделить:
  • ожидаемые перерисовки (нормальная реакция UI),
  • лишние перерисовки (которые можно уменьшить оптимизациями).
MainPage.tsx
import { PageTitle } from "./PageTitle.tsx"
import { Playlist } from "./Playlist.tsx"
import { TrackDetails } from "./TrackDetails.tsx"
import { useState } from "react"
 
export function MainPage() {
  console.log("🎧 MainPage")
  const [selectedTrackId, setSelectedTrackId] = useState<string | null>(null)
 
  const handleTrackSelect = (trackId: string) => {
    setSelectedTrackId(trackId)
  }
 
  return (
    <div>
      <PageTitle value={"Musicfun Player"} />
      <Playlist selectedTrackId={selectedTrackId} onTrackSelect={handleTrackSelect} />
      <hr />
      <TrackDetails selectedTrackId={selectedTrackId} />
    </div>
  )
}
Playlist.tsx
import { useState, useEffect, CSSProperties } from "react"
import { Track, TrackDetailsResourse } from "../types.ts"
 
type Props = {
  selectedTrackId: string | null
  onTrackSelect: (value: string) => void
}
 
export function Playlist(props: Props) {
  console.log("🎶 Playlist")
  const [tracks, setTracks] = useState<Track[] | null>(null)
  const [_, setSelectedTrack] = useState<TrackDetailsResourse | null>(null)
 
  useEffect(() => {
    fetch("https://musicfun.it-incubator.app/api/1.0/playlists/tracks?pageSize=5", {
      headers: {
        "api-key": "XXX",
      },
    })
      .then((res) => res.json())
      .then((json) => setTracks(json.data))
  }, [])
 
  const handleSelectTrack = (trackId: string) => {
    props.onTrackSelect(trackId)
 
    fetch("https://musicfun.it-incubator.app/api/1.0/playlists/tracks/" + props.selectedTrackId, {
      headers: {
        "api-key": "XXX",
      },
    })
      .then((res) => res.json())
      .then((json) => setSelectedTrack(json.data))
  }
  return (
    <div>
      {tracks === null && <span>Loading...</span>}
      {tracks?.length === 0 && <span>No tracks</span>}
      <ul>
        {tracks?.map((track) => {
          const style: CSSProperties = {}
 
          if (track.id === props.selectedTrackId) {
            style.border = "1px solid orange"
          }
          return (
            <li key={track.id} style={style} onClick={() => handleSelectTrack(track.id)}>
              <div>{track.attributes.title}</div>
              <audio controls src={track.attributes.attachments[0].url}></audio>
            </li>
          )
        })}
      </ul>
    </div>
  )
}
TrackDetails.tsx
import { useState, useEffect } from "react"
import { TrackDetailsResourse } from "../types.ts"
 
type Props = {
  selectedTrackId: string | null
}
 
export function TrackDetails(props: Props) {
  console.log("📋 TrackDetails")
  const [selectedTrack, setSelectedTrack] = useState<TrackDetailsResourse | null>(null)
 
  useEffect(() => {
    if (!props.selectedTrackId) return
 
    fetch("https://musicfun.it-incubator.app/api/1.0/playlists/tracks/" + props.selectedTrackId, {
      headers: {
        "api-key": "XXX",
      },
    })
      .then((res) => res.json())
      .then((json) => setSelectedTrack(json.data))
  }, [props.selectedTrackId])
 
  return (
    <div>
      <h2>Track Details</h2>
      {!props.selectedTrackId && <span>No selected track</span>}
      {props.selectedTrackId && !selectedTrack && <span>Loading...</span>}
      {selectedTrack && (
        <div key={}>
          <h4>{selectedTrack.attributes.title}</h4>
          <p>{selectedTrack.attributes.lyrics}</p>
        </div>
      )}
      {selectedTrack && selectedTrack.id !== props.selectedTrackId && <span>Loading...</span>}
    </div>
  )
}

🔸 Ререндеры при первой загрузке

les28-13

  • Порядок отрисовки в консоли:
  1. MainPage
  2. Playlist
  3. TrackDetails
  4. снова Playlist
  • Значит, Playlist перерисовывается несколько раз.

  • Возможные причины ререндера Playlist:

  • изменились props (потому что родитель обновился и передал новые данные);

  • изменилось внутреннее состояние внутри Playlist (через useState).

  • По логам видно: MainPage отрисовался только один раз, значит родитель не триггерит дополнительные ререндеры Playlist.

  • Вывод: Playlist перерисовывает сам себя из-за изменения своего состояния. Типичный пример:

  • сначала компонент рендерится с пустыми/начальными данными;

  • затем подгружаются треки → обновляется state (например, список tracks);

  • после этого Playlist рендерится ещё раз уже с заполненным списком.

🔸 Ререндеры при выборе трека

les28-14

  • При выборе трека перерисовывается MainPage, потому что внутри него меняется состояние: selectedTrackId.

  • Из-за ререндера MainPage он перерисовывает дочерние компоненты:

  • Playlist

  • TrackDetails и передаёт им обновлённый selectedTrackId через props.

  • Дальше наблюдаем «лишние» вызовы:

  • TrackDetails отрисовывается ещё раз — это нормально, потому что внутри него меняется своё состояние (например, подгрузка/обновление деталей трека).

  • Playlist тоже отрисовывается ещё раз — это подозрительно, потому что по логике он не обязан перерисовываться повторно только из-за того, что детали трека обновились.

  • Вывод на этом шаге:

  • ререндер MainPage → ожидаемо тянет Playlist и TrackDetails;

  • второй ререндер TrackDetails → ожидаем из-за его внутреннего состояния;

  • второй ререндер Playlist → причина неочевидна, нужно отдельно проверить, что именно его триггерит.

🔸 Почему Playlist даёт лишний ререндер?

Playlist.tsx
import { useState, useEffect, CSSProperties } from "react"
import { Track, TrackDetailsResourse } from "../types.ts"
 
type Props = {
  selectedTrackId: string | null
  onTrackSelect: (value: string) => void
}
 
export function Playlist(props: Props) {
  console.log("🎶 Playlist")
  const [tracks, setTracks] = useState<Track[] | null>(null)
  const [_, setSelectedTrack] = useState<TrackDetailsResourse | null>(null)
 
  useEffect(() => {
    fetch("https://musicfun.it-incubator.app/api/1.0/playlists/tracks?pageSize=5", {
      headers: {
        "api-key": "XXX",
      },
    })
      .then((res) => res.json())
      .then((json) => setTracks(json.data))
  }, [])
 
  const handleSelectTrack = (trackId: string) => {
    props.onTrackSelect(trackId)
 
    fetch("https://musicfun.it-incubator.app/api/1.0/playlists/tracks/" + props.selectedTrackId, {
      headers: {
        "api-key": "XXX",
      },
    })
      .then((res) => res.json())
      .then((json) => setSelectedTrack(json.data))
  }
 
  return (
    <div>
      {tracks === null && <span>Loading...</span>}
      {tracks?.length === 0 && <span>No tracks</span>}
      <ul>
        {tracks?.map((track) => {
          const style: CSSProperties = {}
 
          if (track.id === props.selectedTrackId) {
            style.border = "1px solid orange"
          }
          return (
            <li key={track.id} style={style} onClick={() => handleSelectTrack(track.id)}>
              <div>{track.attributes.title}</div>
              <audio controls src={track.attributes.attachments[0].url}></audio>
            </li>
          )
        })}
      </ul>
    </div>
  )
}
  • В Playlist есть локальное состояние, которое хранит детали трека:

  • const [_, setSelectedTrack] = useState<TrackDetailsResourse | null>(null)

  • Но детали трека уже запрашивает и отображает отдельный компонент (TrackDetails), поэтому в Playlist это состояние не нужно.

  • Из-за этого в Playlist есть лишняя работа при клике по треку:

  • handleSelectTrack делает правильное действие: вызывает props.onTrackSelect(trackId) и сообщает родителю, какой трек выбран.

  • Но дальше он делает лишний запрос за деталями и кладёт результат в локальный setSelectedTrack(...).

  • Это приводит к дополнительному setState внутри Playlist → и как следствие к лишнему ререндеру Playlist, который нам и мешает при анализе.

  • Ещё одна проблема в текущем запросе:

  • запрос собирается по props.selectedTrackId, хотя только что был выбран trackId;

  • из-за асинхронности обновления props это может быть не тот id (запрос может уйти за предыдущим треком).

  • Что нужно сделать по логике:

  • Playlist отвечает только за список и выбор трека;

  • при клике он должен только вызвать onTrackSelect(trackId);

  • запрос деталей и хранение деталей должны оставаться в TrackDetails.

Playlist.tsx
import { useState, useEffect, CSSProperties } from "react"
import { Track } from "../types.ts"
 
type Props = {
  selectedTrackId: string | null
  onTrackSelect: (value: string) => void
}
 
export function Playlist(props: Props) {
  console.log("🎶 Playlist")
  const [tracks, setTracks] = useState<Track[] | null>(null)
 
  useEffect(() => {
 
      .then((json) => setTracks(json.data))
  }, [])
 
  return (
    <div>
      {tracks === null && <span>Loading...</span>}
      {tracks?.length === 0 && <span>No tracks</span>}
      <ul>
        {tracks?.map((track) => {
          const style: CSSProperties = {}
 
          if (track.id === props.selectedTrackId) {
            style.border = "1px solid orange"
          }
          return (
            <li key={track.id} style={style} onClick={() => onTrackSelect(track.id)}>
              <div>{track.attributes.title}</div>
              <audio controls src={track.attributes.attachments[0].url}></audio>
            </li>
          )
        })}
      </ul>
    </div>
  )
}

🔸 Повторный анализ ререндеров после правок

les28-15

  • При первом открытии:

  • рендерится MainPage, затем Playlist и TrackDetails;

  • в Playlist срабатывает useEffect: делается запрос за треками;

  • треки сохраняются в состояние tracksPlaylist перерисовывается уже с данными;

  • TrackDetails повторно не перерисовывается и не делает запрос, потому что selectedTrackId ещё нет (ничего не выбрано).

  • При выборе трека:

  • обновляется selectedTrackId в родителе → перерисовывается MainPage;

  • вместе с ним перерисовываются Playlist и TrackDetails (это ожидаемо, потому что изменились props);

  • так как появился selectedTrackId, в TrackDetails запускается useEffect и выполняется запрос за деталями трека;

  • сервер возвращает данные → они сохраняются в состояние TrackDetailsTrackDetails перерисовывается второй раз уже с деталями.

  • Главное изменение:

  • Playlist больше не перерисовывается лишний раз, потому что внутри него больше нет лишнего обновления состояния, связанного с деталями трека.

19. 📌 Архитектура front-end, Data Access Layer

Если посмотреть на текущее состояние приложения, то видно, что компоненты у нас занимаются сразу несколькими задачами. Они отрисовывают интерфейс, управляют состоянием, делают запросы к бэкенду и на основе ответа сервера решают, что показать пользователю. Это нормальный этап развития приложения, но со временем такой подход начинает мешать.

les28-16

По своей сути компонент — это презентационный слой. Его главная задача — показать UI и обработать взаимодействие пользователя. Когда в компоненте появляется слишком много другой логики, он становится «толстым» и хуже читается.

Если разложить приложение по зонам ответственности, можно выделить несколько ключевых ролей:

  • отрисовка пользовательского интерфейса;
  • управление данными и состоянием;
  • работа с сервером и API-запросами.

Проблема возникает тогда, когда все эти роли живут внутри одного компонента. Код становится перегруженным, а изменения — более рискованными.

Первый шаг к более чистой архитектуре — вынести API-логику в отдельный слой:

  • туда уходит весь код, связанный с запросами (fetch, URL, заголовки);
  • там же описываются типы данных, которые возвращает сервер;
  • компонент перестаёт знать детали того, как именно получаются данные.

В результате компоненту остаётся простая и понятная роль: когда ему нужны данные, он обращается к API-слою и получает готовый результат, не вникая в детали запроса.

Так мы постепенно приходим к разделению ответственности: каждый слой занимается своим делом, а код становится более понятным, предсказуемым и удобным для развития.

19.1. 🧱 Убираем работу с API из компонентов.

Весь код, который отвечает за запросы к серверу, мы переносим в отдельное место.

  • Создаём папку dal (Data Access Layer).
  • Внутри неё создаём файл api.ts.
  • В этот файл переносим:
  • всю логику с fetch;
  • URL-адреса;
  • headers и API-KEY.
api.ts
export const getTrack = (trackId: string) => {
  return fetch("https://musicfun.it-incubator.app/api/1.0/playlists/tracks/" + trackId, {
    headers: {
      "api-key": "XXX",
    },
  }).then((res) => res.json())
}
TrackDetails.tsx
import { useState, useEffect } from "react"
import { TrackDetailsResourse } from "../types.ts"
import { getTrack } from "../dal/api.ts"
 
type Props = {
  selectedTrackId: string | null
}
 
export function TrackDetails(props: Props) {
  console.log("📋 TrackDetails")
  const [selectedTrack, setSelectedTrack] = useState<TrackDetailsResourse | null>(null)
 
  useEffect(() => {
    if (!props.selectedTrackId) return
 
    getTrack(props.selectedTrackId).then((json) => setSelectedTrack(json.data))
  }, [props.selectedTrackId])
 
  return (
    <div>
      <h2>Track Details</h2>
      {!props.selectedTrackId && <span>No selected track</span>}
      {props.selectedTrackId && !selectedTrack && <span>Loading...</span>}
      {selectedTrack && (
        <div key={}>
          <h4>{selectedTrack.attributes.title}</h4>
          <p>{selectedTrack.attributes.lyrics}</p>
        </div>
      )}
      {selectedTrack && selectedTrack.id !== props.selectedTrackId && <span>Loading...</span>}
    </div>
  )
}
api.ts
//...
 
export const getTracks = () => {
  return fetch("https://musicfun.it-incubator.app/api/1.0/playlists/tracks?pageSize=5", {
    headers: {
      "api-key": "XXX",
    },
  }).then((res) => res.json())
}
Playlist.tsx
import { useState, useEffect, CSSProperties } from "react"
import { Track } from "../types.ts"
import { getTracks } from "../dal/api.ts"
 
type Props = {
  selectedTrackId: string | null
  onTrackSelect: (value: string) => void
}
 
export function Playlist(props: Props) {
  console.log("🎶 Playlist")
  const [tracks, setTracks] = useState<Track[] | null>(null)
 
  useEffect(() => {
    getTracks().then((json) => setTracks(json.data))
  }, [])
 
  return (
    <div>
      {tracks === null && <span>Loading...</span>}
      {tracks?.length === 0 && <span>No tracks</span>}
      <ul>
        {tracks?.map((track) => {
          const style: CSSProperties = {}
 
          if (track.id === props.selectedTrackId) {
            style.border = "1px solid orange"
          }
          return (
            <li key={track.id} style={style} onClick={() => onTrackSelect(track.id)}>
              <div>{track.attributes.title}</div>
              <audio controls src={track.attributes.attachments[0].url}></audio>
            </li>
          )
        })}
      </ul>
    </div>
  )
}

Компоненты больше не делают запросы напрямую. Вместо этого мы экспортируем из api.ts понятные функции, например:

  • getTracks() — для получения списка треков;
  • getTrack(trackId) — для получения деталей конкретного трека.

В итоге компоненту не важно, как именно получаются данные. Он просто вызывает нужную функцию из API-слоя и работает уже с результатом.

19.2. 🌐 Что изменилось после выноса API-слоя

После выноса запросов в dal/api.ts компоненты Playlist и TrackDetails стали «тоньше»: кода в них стало меньше, и главное — из них ушло то, что не относится к их роли.

Важно понимать: «толстый компонент» — это не про количество строк. Это про то, что внутри компонента появляется логика, которая не связана с тем, зачем компонент вообще создавался. Компонент не должен знать детали того, как правильно собирать запрос, какие там URL, заголовки и ключи. Его задача — интерфейс: понять, когда данные нужны, и отрисовать результат.

Теперь компонент работает проще: когда ему нужны данные, он вызывает функцию вроде getTrack(...) из API-слоя. А что происходит внутри этой функции — компоненту не важно. Там может быть реальный запрос на сервер, может быть чтение из localStorage, может быть локальная база в браузере или даже просто возврат заранее подготовленного объекта. В этом и смысл разделения на слои: UI зависит от понятного интерфейса (функций), а детали получения данных спрятаны в отдельном модуле.

Дополнительно в API-слое стало удобно централизовать общие вещи:

  • можно вынести API_KEY в одну константу и переиспользовать;
  • можно вынести headers целиком в одну переменную и использовать в каждом запросе, вместо того чтобы копировать одно и то же.

Дальше делаем небольшой рефакторинг: создаём общий headers в api.ts, подключаем его во всех запросах и проверяем, что после изменений приложение работает так же, как и раньше.

api.ts
const apiKey = "XXX"
const headers = {
  "api-key": apiKey,
}
 
export const getTrack = (trackId: string) => {
  return fetch("https://musicfun.it-incubator.app/api/1.0/playlists/tracks/" + trackId, {
    headers: headers,
  }).then((res) => res.json())
}
 
export const getTracks = () => {
  return fetch("https://musicfun.it-incubator.app/api/1.0/playlists/tracks?pageSize=5", {
    headers: headers,
  }).then((res) => res.json())
}

20. 📌 Архитектура front-end, Business Logic Layer

После выноса dal/api.ts мы отделили один важный аспект приложения: доступ к данным (Persistence / Data Access Layer). Это место, которое знает, откуда и как брать данные: по HTTP, из localStorage, из памяти, через service worker — неважно. Компоненту это не интересно: он не должен знать ни транспорт, ни протокол, ни детали получения данных.

Но кроме доступа к данным есть ещё один отдельный аспект, который с ростом приложения становится ещё важнее — управление состоянием. Сейчас стейт маленький: несколько useState, пара запросов. Но дальше он начнёт разрастаться, появятся правила, ограничения, зависимости, и тогда смешивать UI и логику данных внутри компонентов станет болезненно.

Когда мы говорим про управление состоянием, мы имеем в виду не просто «положить в useState», а держать данные в порядке:

  • соблюдать бизнес-правила и ограничения;
  • поддерживать корректные переходы состояния;
  • управлять загрузкой, ошибками, зависимостями между данными;
  • обеспечивать предсказуемое поведение UI на основе этих данных.

les28-17

Идея по цепочке выглядит так:

  • компонент говорит: «мне нужен список треков / плейлистов»;
  • state-слой решает, что делать: когда грузить, как хранить, что считать текущим состоянием;
  • если нужно — state-слой обращается в dal и забирает данные.

И тут важный момент: в React управление состоянием всё равно завязано на хуки (useState, useEffect). Чтобы отделить логику управления данными от логики отрисовки, мы оформляем её в кастомные хуки.

Кастомный хук — это функция, которая начинается с use... и внутри использует базовые хуки React (или другие кастомные хуки). Мы выносим туда логику: загрузку, обновления состояния, обработку ошибок, выбор активного трека и т.д., а компонент оставляем максимально простым.

Ключевая идея всей перестройки такая: самый “толстый” слой должен быть там, где живёт логика данных и правил, а компонент — это тонкий презентационный слой, который просто отображает состояние и вызывает события.

21. 📌 Кастомные хуки

Мы переходим к следующему шагу архитектуры: создаём кастомные хуки и выносим туда то, что не относится к отрисовке UI. Идея в том, чтобы при “проваливании” в компонент (например, в MainPage) мы видели в основном JSX и алгоритм рендера, а не кучу useState/useEffect.

🧩

Хук — это функция React вроде useState, useEffect. Кастомный хук — наша функция, которая начинается с use... и внутри использует другие хуки. Его цель — собрать и изолировать логику управления данными/состоянием, чтобы компоненты оставались презентационными.

21.1. 🔹 Кастомный хук для MainPage

Начинаем с компонента MainPage: там есть JSX и есть логика управления состоянием (например, выбранный трек и обработчик выбора). Эту логику мы выносим в кастомный хук.

  • Создаём кастомный хук (например, useMainPage).
  • Переносим в него все useState/useEffect и связанные функции.
  • Из хука возвращаем наружу то, что нужно компоненту для работы:
  • selectedTrackId
  • handleTrackSelect
MainPage.tsx
import { PageTitle } from "./PageTitle.tsx"
import { Playlist } from "./Playlist.tsx"
import { TrackDetails } from "./TrackDetails.tsx"
import { useState } from "react"
 
function useTrackSelection() {
  const [selectedTrackId, setSelectedTrackId] = useState<string | null>(null)
 
  const handleTrackSelect = (trackId: string) => {
    setSelectedTrackId(trackId)
  }
 
  return {
    selectedTrackId,
    handleTrackSelect,
  }
}
 
export function MainPage() {
  console.log("🎧 MainPage")
  const { selectedTrackId, handleTrackSelect } = useTrackSelection()
 
  return (
    <div>
      <PageTitle value={"Musicfun Player"} />
      <Playlist selectedTrackId={selectedTrackId} onTrackSelect={handleTrackSelect} />
      <hr />
      <TrackDetails selectedTrackId={selectedTrackId} />
    </div>
  )
}

Внутри MainPage остаётся простая картина: мы вызываем хук, забираем нужные значения и используем их в JSX.

⚠️

Кастомный хук не “делится” состоянием между компонентами. Это просто функция-обёртка: состояние создаётся для того компонента, который вызывает хук. Если другой компонент вызовет useTrackSelection, он получит своё отдельное состояние.

21.2. 🔹 Кастомный хук для Playlist

В хук переносим useState и useEffect, потому что они напрямую связаны: эффект загружает треки и сразу сохраняет их в локальное состояние. У них высокая связность, поэтому держим их вместе.

Playlist.tsx
import { useState, useEffect, CSSProperties } from "react"
import { Track } from "../types.ts"
import { getTracks } from "../dal/api.ts"
 
type Props = {
  selectedTrackId: string | null
  onTrackSelect: (value: string) => void
}
 
function useTracks() {
  const [tracks, setTracks] = useState<Track[] | null>(null)
 
  useEffect(() => {
    getTracks().then((json) => setTracks(json.data))
  }, [])
 
  return {
    tracks,
  }
}
 
export function Playlist(props: Props) {
  console.log("🎶 Playlist")
  const { tracks } = useTracks()
 
  return (
    <div>
      {tracks === null && <span>Loading...</span>}
      {tracks?.length === 0 && <span>No tracks</span>}
      <ul>
        {tracks?.map((track) => {
          const style: CSSProperties = {}
 
          if (track.id === props.selectedTrackId) {
            style.border = "1px solid orange"
          }
          return (
            <li key={track.id} style={style} onClick={() => onTrackSelect(track.id)}>
              <div>{track.attributes.title}</div>
              <audio controls src={track.attributes.attachments[0].url}></audio>
            </li>
          )
        })}
      </ul>
    </div>
  )
}

После переноса логика “закрывается” внутри хука, и компоненту не хватает данных для рендера. Нам нужны только треки, поэтому хук возвращает { tracks }. setTracks наружу не отдаём — он остаётся внутренним и используется только внутри хука.

Так поток данных становится чище: Playlist отображает данные, но не управляет ими напрямую.

21.3. 🔹 Кастомный хук для TrackDetails

Переходим к компоненту TrackDetails и делаем тот же архитектурный шаг — выносим бизнес-логику в кастомный хук.

🧠

Кастомный хук — это функция, начинающаяся с use, которая инкапсулирует логику работы со state и эффектами, но сама по себе не является компонентом и не получает props автоматически.

Для начала создаём хук useTrackDetails (позже для консистентности переименуем в useTrack) и переносим туда:

  • useState — для хранения деталей трека;
  • useEffect — для загрузки данных.

После переноса смотрим на компонент и понимаем, чего ему теперь не хватает. Компоненту нужен выбранный трек, значит именно его мы возвращаем из хука.

⚠️

Хук — это обычная функция. Он не знает, что такое props.

Поэтому мы явно передаём в хук только то, что ему действительно нужно — selectedTrackId: string | null. Передавать весь объект пропсов — плохая практика.

TrackDetails.tsx
import { useState, useEffect } from "react"
import { TrackDetailsResourse } from "../types.ts"
import { getTrack } from "../dal/api.ts"
 
type Props = {
  selectedTrackId: string | null
}
 
function useTrack(selectedTrackId: string | null) {
  const [selectedTrack, setSelectedTrack] = useState<TrackDetailsResourse | null>(null)
 
  useEffect(() => {
    if (!selectedTrackId) return
 
    getTrack(selectedTrackId).then((json) => setSelectedTrack(json.data))
  }, [selectedTrackId])
 
  return { selectedTrack }
}
 
export function TrackDetails(props: Props) {
  console.log("📋 TrackDetails")
  const { selectedTrack } = useTrack(props.selectedTrackId)
 
  return (
    <div>
      <h2>Track Details</h2>
      {!props.selectedTrackId && <span>No selected track</span>}
      {props.selectedTrackId && !selectedTrack && <span>Loading...</span>}
      {selectedTrack && (
        <div key={}>
          <h4>{selectedTrack.attributes.title}</h4>
          <p>{selectedTrack.attributes.lyrics}</p>
        </div>
      )}
      {selectedTrack && selectedTrack.id !== props.selectedTrackId && <span>Loading...</span>}
    </div>
  )
}

В итоге схема становится такой:

  • TrackDetails — чистый UI;
  • он вызывает useTrack(selectedTrackId) и говорит: «дай мне трек для этого ID»;
  • если трек ещё не загружен — хук возвращает null;
  • внутри хука useEffect загружает данные и обновляет state, вызывая ререндер.

Компоненту не важно, откуда берётся трек: из API, localStorage, IndexedDB или хоть «из космоса». Этим занимается бизнес-логика.

19.4. 🔹 Переносим кастомные хуки в папку bll

На этом этапе мы вводим отдельную папку bllBusiness Logic Layer:

  • переносим туда все кастомные хуки;
  • хуки называем в camelCase с маленькой буквы (useTrack, useTracks, useTrackSelection);
  • компоненты оставляем в PascalCase.
      • useTracks.ts
      • useTrack.ts
      • useTrackSelection.ts
      • TrackDetails.tsx
      • Playlist.tsx
      • MainPage.tsx
      • ...

    • ...

  • В BLL нет JSX, поэтому файлы должны быть с расширением .ts, а не .tsx.

    В итоге у нас появляется несколько независимых кастомных хуков, каждый из которых отвечает за управление состоянием и загрузку данных, а компоненты остаются тонкими презентационными слоями.

    20. 📌 Ещё раз про архитектуру, типы, анализ кода

    На этом этапе мы окончательно договариваемся, что кастомные хуки относятся к бизнес-логике. Именно здесь сосредоточена работа со state: его хранение, обновление, реакции на изменения и загрузка данных в нужный момент.

    🧩

    Бизнес-логика отвечает за когда и как загружать данные: при первом монтировании компонента, при изменении id, при повторных обновлениях. Компоненту важен результат, а не сам процесс.

    Например:

    • загрузка списка треков происходит один раз — при первом монтировании;
    • загрузка деталей трека происходит каждый раз, когда меняется selectedTrackId;
    • это контролируется зависимостями в useEffect, а не самим компонентом.

    Мы можем не привязываться напрямую к понятию «жизненного цикла», но на практике мы всё равно мыслим именно так: компонент родился → эффект сработал → данные загрузились → UI обновился.

    20.1. 🔹 Три слоя приложения

    В итоге у нас формируется чёткая структура из трёх слоёв:

    • UI Layer

    • компоненты;

    • JSX;

    • алгоритм рендера;

    • никаких деталей про API и бизнес-правила.

    • Business Logic Layer (BLL)

    • кастомные хуки;

    • управление состоянием;

    • решения когда и что загружать;

    • импортирует Data Access Layer;

    • ничего не знает про компоненты.

    • Data Access Layer (DAL)

    • работа с API;

    • fetch, URL, headers, API-key;

    • типы данных, приходящие с бэкенда;

    • не имеет импортов вообще.

    ⚠️

    Data Access Layer не знает ни про UI, ни про бизнес-логику. Это независимый модуль, который можно вынести в другой проект и переиспользовать.

    Направление зависимостей строго однонаправленное:

    • UI → Business Logic → Data Access

    UI может напрямую обратиться к DAL — мы так делали раньше. Иногда это допустимо для быстрых решений. Но архитектурно мы сознательно разделяем эти слои, чтобы приложение масштабировалось без боли.

    20.2. 🔹 Типы данных

    Типы треков мы также переносим в Data Access Layer. Они отражают контракт с бэкендом и логично живут рядом с API-кодом. Это подчёркивает идею, что:

    • структура данных приходит извне;
    • остальное приложение подстраивается под DAL, а не наоборот.

    В итоге компоненты становятся тонкими, бизнес-логика — явной и управляемой, а доступ к данным — изолированным и переиспользуемым.

      • useTracks.ts
      • useTrack.ts
      • useTrackSelection.ts
      • api.ts
      • types.ts
      • TrackDetails.tsx
      • Playlist.tsx
      • MainPage.tsx
      • ...

  • 21. 🧱 Компонент TrackItem

    Внутри Playlist у нас отрисовывается каждый трек, и логика получается шумной: стили, сравнения, обработчики клика — всё вперемешку. Поэтому мы выносим отдельный строительный блок TrackItem.

    🔸 Как передаём выделение выбранного трека

    Есть два варианта, как объяснить TrackItem, выбран он или нет:

    • передать selectedTrackId и внутри TrackItem сравнивать track.id === selectedTrackId;
    • или проще для TrackItem: передать сразу готовый флаг isSelected: boolean.

    Мы выбираем второй вариант: решение принимаем “снаружи”, а TrackItem просто отображает себя правильно.

    🎯

    TrackItem не обязан знать про “другие треки”. Ему достаточно получить isSelected и применить стиль, если нужно.

    🔸 Как поднимаем клик наверх

    TrackItem должен сообщить наверх, что по нему кликнули. Для этого добавляем проп:

    • onTrackSelect(trackId: string): void

    И дальше важная оптимизация по смыслу: если Playlist ничего не делает “в середине”, то он может не создавать лишние обёртки на каждую итерацию, а просто пробросить callback дальше.

    То есть вместо handleClick на каждом map:

    • мы делегируем onTrackSelect прямо в TrackItem,
    • а TrackItem вызывает его, передавая свой track.id.

    🔸 Про key в списке

    key ставится на элемент, который создаётся в цикле (TrackItem в месте map). Внутри самого TrackItem key не нужен — там мы его не пишем.

    ⚠️

    key всегда задаётся в месте map, а не внутри дочернего компонента.

    Playlist.tsx
    import { CSSProperties } from "react"
    import { useTracks } from "../bll/useTracks"
    import type { Track } from "../dal/types.ts"
     
    type TrackItemProps = {
      isSelect: boolean
      track: Track
      onTrackSelect: (trackId: string) => void
    }
     
    function TrackItem(props: TrackItemProps) {
      const style: CSSProperties = {}
     
      if (props.isSelect) {
        style.border = "1px solid orange"
      }
      return (
        <li key={props.track.id} style={style} onClick={() => props.onTrackSelect(props.track.id)}>
          <div>{props.track.attributes.title}</div>
          <audio controls src={props.track.attributes.attachments[0].url}></audio>
        </li>
      )
    }
     
    type Props = {
      selectedTrackId: string | null
      onTrackSelect: (value: string) => void
    }
     
    export function Playlist(props: Props) {
      console.log("🎶 Playlist")
      const { tracks } = useTracks()
     
      return (
        <div>
          {tracks === null && <span>Loading...</span>}
          {tracks?.length === 0 && <span>No tracks</span>}
          <ul>
            {tracks?.map((track) => {
              return (
                <TrackItem
                  key={track.id}
                  isSelect={track.id === props.selectedTrackId}
                  track={track}
                  onTrackSelect={props.onTrackSelect}
                />
              )
            })}
          </ul>
        </div>
      )
    }

    🔸 Файловая структура

    После того как TrackItem готов, выносим его в UI-слой отдельным файлом.

      • Playlist.tsx
      • TrackItem.tsx
      • TrackDetails.tsx
      • MainPage.tsx
      • useTracks.ts
      • useTrack.ts
      • useTrackSelection.ts
      • api.ts
      • types.ts
  • После рефакторинга проверяем, что всё работает: треки грузятся, выделение выбранного трека есть, клик корректно поднимается наверх.

    22. 🎨 Module CSS, clsx

    Последний важный момент в этом модуле — стилизация компонентов и работа с модульными CSS. Фронтенд — это не только логика и архитектура, но и внешний вид. Дизайнеры рисуют макеты, а мы должны уметь аккуратно и безопасно переносить стили в код.

    Мы уже работали с обычными CSS-файлами: импортируем файл — сборщик понимает, что его нужно включить в итоговую сборку. Проблема такого подхода в том, что все CSS-классы глобальные. Если в разных файлах появится один и тот же класс, начинается конфликт, и становится непонятно, какие стили в итоге применятся.

    Существуют методологии вроде BEM, которые решают эту проблему через строгие правила нейминга. Это рабочий подход, но он требует дисциплины и быстро усложняет код. Хочется, чтобы уникализация происходила автоматически.

    Playlist.tsx
    import { useTracks } from "../bll/useTracks"
    import { TrackItem } from "./TrackItem.tsx"
    import "./Playlist.css"
     
    type Props = {
      selectedTrackId: string | null
      onTrackSelect: (value: string) => void
    }
     
    export function Playlist(props: Props) {
      console.log("🎶 Playlist")
      const { tracks } = useTracks()
     
      return (
        <div>
          {tracks === null && <span>Loading...</span>}
          {tracks?.length === 0 && <span>No tracks</span>}
          <ul className={"tracks"}>
            {tracks?.map((track) => {
              return (
                <TrackItem
                  key={track.id}
                  isSelect={track.id === props.selectedTrackId}
                  track={track}
                  onTrackSelect={props.onTrackSelect}
                />
              )
            })}
          </ul>
        </div>
      )
    }
    Playlist.css
    .tracks {
      display: flex;
      flex-direction: column;
      gap: 10px;
    }
     
    /*src/ui/tracks.css*/
    .track {
      border: 1px solid #747bff;
      border-radius: 8px;
      padding: 10px;
    }
     
    .track.selected {
      border-color: #ffc464;
    }
    🎨

    CSS Modules — это способ сделать классы локальными для конкретного компонента. Сборщик автоматически генерирует уникальные имена классов, и конфликты исчезают.

    Playlist.module.css
    .tracks {
      display: flex;
      flex-direction: column;
      gap: 10px;
    }
    Playlist.tsx
    import { useTracks } from "../bll/useTracks"
    import { TrackItem } from "./TrackItem.tsx"
    import styles from "./Playlist.module.css"
     
    type Props = {
      selectedTrackId: string | null
      onTrackSelect: (value: string) => void
    }
     
    export function Playlist(props: Props) {
      console.log("🎶 Playlist")
      const { tracks } = useTracks()
     
      return (
        <div>
          {tracks === null && <span>Loading...</span>}
          {tracks?.length === 0 && <span>No tracks</span>}
          <ul className={styles.tracks}>
            {tracks?.map((track) => {
              return (
                <TrackItem
                  key={track.id}
                  isSelect={track.id === props.selectedTrackId}
                  track={track}
                  onTrackSelect={props.onTrackSelect}
                />
              )
            })}
          </ul>
        </div>
      )
    }

    🎨 Как работает модульный CSS

    Когда мы переименовываем файл в формат *.module.css, мы даём понять сборщику, что это не обычный CSS. Такой файл при импорте превращается в объект, где:

    • ключи — это имена классов, которые мы описали в CSS;
    • значения — автоматически сгенерированные уникальные строки.

    В DOM попадает именно эта уникальная строка, а не исходное имя класса. За счёт этого:

    • одинаковые имена классов в разных компонентах не конфликтуют;
    • стили жёстко привязаны к конкретному компоненту.

    Если посмотреть на элемент в DevTools, мы увидим не track или selected, а “абракадабру” с хэшем. Это нормально и именно этого мы добиваемся.

    🧱 Один компонент — один файл стилей

    Мы придерживаемся простого и удобного правила:

    • один UI-компонент → один CSS Module.

    Например:

    • Playlist отвечает только за список → Playlist.module.css;
    • TrackItem отвечает за отображение одного трека → TrackItem.module.css.

    Так стили инкапсулируются вместе с компонентом и не «протекают» наружу.

    TrackItem.module.css
    .track {
      border: 1px solid #747bff;
      border-radius: 8px;
      padding: 10px;
    }
     
    .track.selected {
      border-color: #ffc464;
    }
    TrackItem.tsx
    import { CSSProperties } from "react"
    import type { Track } from "../dal/types.ts"
    import styles from "./TrackItem.module.css"
     
    type TrackItemProps = {
      isSelect: boolean
      track: Track
      onTrackSelect: (trackId: string) => void
    }
     
    export function TrackItem(props: TrackItemProps) {
      const style: CSSProperties = {}
     
      if (props.isSelect) {
        style.border = "1px solid orange"
      }
     
      const handleClick = () => {
        props.onTrackSelect(props.track.id)
      }
      return (
        <li style={style} className={styles.track}>
          <div onClick={handleClick}>{props.track.attributes.title}</div>
          <audio controls src={props.track.attributes.attachments[0].url}></audio>
        </li>
      )
    }

    🎨 Управление состояниями через классы, а не inline-стили

    Ранее выделение выбранного трека делалось через inline-стили. Это работает, но:

    • inline-стили имеют более высокий приоритет и легко ломают CSS;
    • они засоряют JSX и усложняют чтение компонента.

    Более правильный подход — управлять внешним видом через классы:

    • базовый класс применяется всегда;
    • дополнительный класс добавляется при выполнении условия (например, isSelected).
    TrackItem.tsx
    import type { Track } from "../dal/types.ts"
    import styles from "./TrackItem.module.css"
     
    type TrackItemProps = {
      isSelect: boolean
      track: Track
      onTrackSelect: (trackId: string) => void
    }
     
    export function TrackItem(props: TrackItemProps) {
      let classes = styles.track
      if (props.isSelect) {
        classes += " " + styles.selected
      }
     
      const handleClick = () => {
        props.onTrackSelect(props.track.id)
      }
      return (
        <li className={classes}>
          <div onClick={handleClick}>{props.track.attributes.title}</div>
          <audio controls src={props.track.attributes.attachments[0].url}></audio>
        </li>
      )
    }

    Таким образом:

    • логика выбора остаётся в коде;
    • визуальное оформление — в CSS;
    • компонент остаётся чистым и читаемым.
    ⚠️

    В CSS Modules лучше использовать имена классов в camelCase, чтобы удобно обращаться к ним как к свойствам объекта и не получать неожиданные преобразования.

    Когда условий становится много, ручная сборка строк с классами начинает мешать. В реальных проектах для этого используют специальные утилиты, но сама идея остаётся той же: никаких inline-стилей, только классы и модульный CSS.

    Так мы сохраняем архитектурную чистоту и на уровне логики, и на уровне визуального оформления.

    22.1. 🔹 clsx и декларативная работа с классами

    Когда в компонентах появляется много условий для стилизации, быстро начинается хаос: if / else, ручные конкатенации строк, проверки на true / false, плюсики, пробелы — всё это засоряет код и отвлекает от сути.

    Поэтому мы подключаем небольшую утилиту — clsx.

    bash
    npm install clsx
    🧩

    clsx — это библиотека, которая помогает декларативно собирать className на основе условий, без ручных if и конкатенаций строк.

    Идея очень простая: мы описываем какие классы хотим получить и при каких условиях, а не как их склеивать.

    Мы передаём в clsx объект:

    • ключи — это классы;
    • значения — условия, при которых класс должен попасть в итоговую строку.

    Если значение true — класс добавляется. Если false — класс игнорируется.

    TrackItem.tsx
    import type { Track } from "../dal/types.ts"
    import styles from "./TrackItem.module.css"
    import clsx from "clsx"
     
    type TrackItemProps = {
      isSelect: boolean
      track: Track
      onTrackSelect: (trackId: string) => void
    }
     
    export function TrackItem(props: TrackItemProps) {
      const classNames = clsx({
        [styles.track]: true,
        [styles.selected]: props.isSelect,
      })
     
      const handleClick = () => {
        props.onTrackSelect(props.track.id)
      }
      return (
        <li className={classNames}>
          <div onClick={handleClick}>{props.track.attributes.title}</div>
          <audio controls src={props.track.attributes.attachments[0].url}></audio>
        </li>
      )
    }

    Таким образом:

    • базовый класс (например, track) всегда включён;
    • дополнительный класс (например, selected) включается только при выполнении условия.

    В итоге мы получаем одну итоговую строку className, не думая про пробелы, порядок и конкатенацию.

    ⚠️

    Это переход от императивного стиля (“если так — прибавь строку”) к декларативному стилю (“я хочу такие классы при таких условиях”).

    И это очень важно концептуально. Декларативность — ключевая идея React:

    • в JSX мы описываем что хотим видеть, а не как это рисовать;
    • императивный код скрыт внутри React;
    • точно так же императивная логика склейки классов скрыта внутри clsx.

    🎨 CSS Modules и отсутствие конфликтов

    Дальше мы усиливаем эту идею с помощью CSS Modules. Мы можем спокойно использовать одинаковые имена классов в разных компонентах, например track, потому что:

    • каждый .module.css превращается в объект со своими уникальными значениями;
    • итоговые классы получают разные хэши;
    • конфликты полностью исключены.

    Даже если:

    • в TrackItem есть класс track;
    • и в TrackDetails есть класс track;

    они никогда не пересекутся, потому что:

    • имена вычисляются на основе пути к файлу;
    • сборщик автоматически добавляет уникальный хэш.

    В DevTools мы увидим разные значения классов, хотя в коде они называются одинаково. И это именно то, чего мы хотим.

    🎨 Итог по стилизации

    • CSS Modules дают локальность и защиту от конфликтов.
    • clsx даёт чистый, декларативный способ управлять классами.
    • Компоненты не захламлены стилями и условиями.
    • Мы не думаем о неймингах вроде track-wrapper, track-container, track-widget.
    • Мы называем классы по смыслу и доверяем инфраструктуре.

    Так мы сохраняем архитектурную чистоту не только в логике, но и в визуальном слое приложения.

    23. 🕹️ Git, система контроля версий

    В качестве результата первого модуля мы задеплоим наше приложение на настоящий хостинг. Делать это будем не «вручную», а профессионально — через CI/CD pipeline. Но прежде чем к этому прийти, нужно заложить базу. И начинается она со знакомства с системой контроля версий Git. Не пугайтесь — будем разбираться постепенно.

    ❔Зачем вообще нужен Git

    Система контроля версий решает одну ключевую проблему: она позволяет не терять историю проекта. У нас есть текущее состояние приложения — набор файлов и их содержимое. Как только мы что-то меняем (добавляем строку, удаляем файл, делаем рефакторинг), версия приложения меняется.

    Если Git не использовать, мы не можем:

    • вернуться назад и посмотреть, что было раньше;
    • понять, где именно мы что-то сломали;
    • безопасно экспериментировать с кодом.

    Git даёт возможность буквально «путешествовать во времени» по версиям проекта. Это особенно важно, потому что ошибки и неудачные изменения — нормальная часть разработки.

    🤼‍♂️ Git и командная разработка

    les28-18

    В реальной разработке над проектом почти всегда работает несколько разработчиков. Работа идёт параллельно:

    • есть версия 1;
    • коллективно появляется версия 2;
    • дальше разработчики расходятся по разным направлениям;
    • кто-то делает одну фичу, кто-то — другую;
    • какие-то ветки оказываются тупиковыми и отбрасываются;
    • в итоге рабочие изменения объединяются в новую версию.

    Именно для этого Git и существует: версионирование истории, работа в ветках, объединение изменений, откаты, восстановление состояний.

    ‼️ Git ≠ GitHub

    В рамках курса мы используем Git — систему контроля версий. Это локальный инструмент, который устанавливается на компьютер.

    ⚠️

    Git — это не GitHub. GitHub мы разберём позже. Сейчас нам нужен именно Git.

    🔸 Инициализация репозитория

    После установки Git мы можем сказать, что текущая папка — это не просто папка, а Git-репозиторий. Для этого:

    • открываем терминал в папке проекта;
    • инициализируем репозиторий.
    bash
     
    git init
     

    С этого момента папка становится «умной»: Git начинает отслеживать изменения и хранить историю.

    IDE (например, WebStorm) сразу это понимает и начинает помогать работать с Git через интерфейс.

    🔸 Untracked файлы и .gitignore

    После инициализации Git сообщает, что есть неотслеживаемые (untracked) файлы — файлы проекта, которые Git пока не наблюдает.

    При этом в репозитории не должно быть:

    • node_modules — папка восстанавливается через npm install;
    • .idea — локальные настройки IDE;
    • dist / build-папок.

    Git их не показывает, потому что в проекте есть файл .gitignore — он говорит Git, какие файлы и папки нужно игнорировать.

    📚 Git status и реальные термины

    Команда git status показывает текущее состояние репозитория. Важно понимать реальные термины Git:

    • untracked — файлы, которые Git ещё не отслеживает;
    • modified — файлы, которые уже отслеживаются и были изменены;
    • staged — файлы, подготовленные к коммиту.

    IDE часто упрощают эти названия, из-за чего возникает путаница. Важно ориентироваться именно на термины Git.

    🔸 Staging и коммит

    Перед тем как зафиксировать версию проекта, есть два шага:

    1. Добавить файлы в staging area — подготовить их к коммиту.
    2. Сделать commit — сохранить слепок текущего состояния проекта.

    Коммит — это точка, к которой Git может в любой момент вернуться. После коммита рабочее дерево становится чистым — изменений нет.

    🔸 Изменения после коммита

    Как только файл был закоммичен, он становится постоянно отслеживаемым. Любые изменения в нём Git помечает как modified.

    Эти изменения можно:

    • закоммитить как новую версию;
    • или откатить, если правка была ошибочной.

    🔸 История и rollback

    Git позволяет:

    • посмотреть, какие изменения были сделаны;
    • сравнить текущую версию с предыдущей;
    • откатить неудачные изменения и вернуть рабочее состояние.

    Даже если что-то случайно сломали или удалили, история никуда не пропадает — Git всё помнит.

    24. 🐈‍⬛ GitHub

    Локальный репозиторий Git хранится на компьютере. В случае поломки ПК или сброса системы все данные могут быть потеряны. Удалённый репозиторий в облачном хранилище, например, на GitHub, решает эту проблему:

    • обеспечивает резервное копирование (бэкап) исходного кода и всей истории изменений;
    • позволяет совместную работу, когда другие участники команды могут клонировать репозиторий, вносить изменения и синхронизировать их с общим хранилищем;
    • гарантирует, что код будет сохранён и доступен даже при утрате локальной копии.

    gitHub

    GitHub — это крупнейший хостинг Git-репозиториев. Он предоставляет удалённое хранилище кода и истории версий, позволяя работать с Git не только локально, но и через интернет. GitHub не является Git-репозиторием — это удалённое хранилище, где размещаются репозитории, созданные с помощью Git. Хотя GitHub является самым популярным хостингом Git-репозиториев, существуют альтернативы: GitLab, Bitbucket, а также self-hosted Git-сервера.

    📌

    GitHubGoogle Drive — он не синхронизирует автоматически, а позволяет осознанно контролировать версии и коммиты.

    Удаленные репозитории могут быть:

    • публичными: видны всем пользователям, каждый может клонировать репозиторий себе локально.
    • приватными: доступ ограничен, требует явного предоставления доступа другим разработчикам.

    🔸 Создание удалённого репозитория

    Для начала работы необходимо создать аккаунт или авторизоваться на GitHub.

    1. В профиле выбрать → RepositoriesNew repository.
    2. Далее задайте имя, например youtube-lesson.
    3. Выберите, какой репозиторий хотите создать(публичный или приватный).
    4. Нажать Create repository.

    les28-19

    🎉 Готово! Вы создали новый, возможно первый свой репозиторий.

    GitHub покажет инструкцию с командами и ссылку на репозиторий. Чтобы отобразить ссылку, нажмите на HTTPS:

    les28-20

    🔸 Связь локального и удалённого репозитория

    Чтобы связать наш локальный репозиторий с GitHub, используем команду:

    Terminal
    git remote add origin <URL_репозитория>
    • origin — стандартное имя удалённого репозитория.

    Чтобы проверить, что мы действительно связали наши репозитории выполним команду:

    Terminal
    git remote -v

    💾 Отправка изменений на GitHub (Push)

    Для первой отправки всех наших изменений из локального хранилища на GitHub используем команду:

    Terminal
    git push -u origin main

    Git требует авторизацию. Это происходит, потому что публичность дает доступ только на чтение.

    📌

    Если репозиторий публичный — любой человек может:

    • просматривать содержимое,
    • скачивать,
    • клонировать.

    ❗ Но изменить его — может только владелец или специально приглашённые участники.

    В userName указываем свой ник для gitHub, а в password необходимо положить Personal Access Token (PAT). Этот токен необходимо создать в личном кабинете:

    🌌

    GitHub → ⚙️ Settings → Developer Settings → Personal access tokens → Fine-grained tokens → Generate new token.

    Задаем имя для нашего токена, например youtube-lesson и выбираем срок действия. Для безопасности лучше ограничить срок действия токена.

    token

    Далее выбираем доступ к нужным репозиториям (только к публичным, ко всем или какому-то конкретному) и пока предоставим все permissions из списка, открывающего при нажатии на Add permissions:

    tokenPermissions

    В permissions для Contents изменим read-only на read and write:

    readWrite

    Нажимаем Generate token, токен появляется в списке и можно его скопировать:

    les28-21

    И теперь снова пробуем запушить изменения и подставить userName и token:

    Terminal
    git push -u origin main
    userName for 'https://github.com': 'your_userName'
    password for 'https://your_userName@github.com': 'your_token'

    🚀 Все данные запушились на gitHub!

    📌

    При последующих push не нужно будет указывать дополнительные флаги, выполняем команду: git push. Авторизация также не будет запрашиваться.

    Когда мы выполняем команду git commit -m изменения сохраняются только локально. Чтобы отправить их в удаленный репозиторий необходимо после этого выполнить команду git push!

    🔸 Откат изменений

    Самый простой способ отменить изменения — сделать новый коммит с нужными правками.

    Terminal
    git add 'changed_file'
    git commit -m "revert: reverted to previous version"
    git push

    История останется последовательной, но код вернётся к прежнему виду.

    🔸 Клонирование репозитория

    Так как у нас теперь есть удаленный репозиторий со всеми необходимыми данными, нам не страшно потерять локальную версию. Мы можем клонировать наш репозиторий с gitHub.

    Открываем в нужной нам папке терминал и выполняем команду clone с указанием ссылки на репозиторий:

    Terminal
    git clone https://github.com/username/yourRepo.git

    Адрес берем не из адресной строки, а из Code:

    les28-22

    ✅ Результат:

    • Создаётся новая папка с именем репозитория.
    • Скачивается вся история коммитов.
    • Автоматически настраивается remote origin.

    25. ⚙️ CI/CD, GitHub Actions, GitHub Pages

    После того как мы научились коммитить и пушить изменения в удалённый репозиторий, возникает логичный вопрос: какое вообще отношение Git имеет к деплою и хостингу?

    Короткий ответ — самое прямое. Практически все современные CI/CD pipeline начинаются именно с Git.

    🔸 Как фронтенд-приложение попадает в браузер

    les28-23

    На локальной машине всё выглядит просто:

    • мы открываем браузер;
    • заходим на localhost:5173;
    • видим работающее приложение.

    Но важно понимать, что именно туда попадает и каким образом.

    Фронтенд-приложение — это набор HTML, CSS и JavaScript файлов. Чтобы браузер их получил, нужен сервер, который:

    • слушает запросы на определённом порту;
    • отдаёт эти файлы в ответ.

    Когда мы запускаем проект в dev-режиме (npm run dev):

    • Vite поднимает локальный сервер;
    • на лету компилирует TypeScript, TSX, CSS Modules;
    • отдает браузеру готовые HTML / CSS / JS.

    Это dev-режим.

    🔸 Build ≠ Dev

    Если нам не нужен локальный сервер, а нужно просто получить готовые файлы, которые можно разместить на хостинге, используется другой шаг — build.

    Команда build:

    • не поднимает сервер;
    • компилирует и транспилирует проект;
    • кладёт результат в отдельную папку (dist).

    Во время build:

    • TypeScript проверяется строго;
    • любые ошибки ломают сборку;
    • код минифицируется и оптимизируется.

    В результате появляется папка dist, в которой лежит:

    • index.html;
    • ассеты (JS, CSS);
    • полностью готовая продакшен-версия приложения.

    Папка dist — это то, что реально нужно хостингу.

    🌐 Статика и сервер

    Важно понимать: Vite больше не нужен, чтобы приложение работало.

    Любой сервер, который умеет раздавать статические файлы, может:

    • взять содержимое dist;
    • отдавать его браузеру по HTTP;
    • и приложение будет работать.

    Это и есть фактический результат сборки фронтенда.

    🌿 Где здесь Git и CI/CD

    les28-24

    Теперь собираем всю цепочку целиком:

    1. Мы пишем код локально.
    2. Коммитим изменения.
    3. Пушим их в удалённый Git-репозиторий.
    4. На пуш реагирует CI/CD сервер.
    5. CI/CD сервер:
    • клонирует репозиторий;
    • устанавливает зависимости;
    • запускает build;
    • забирает папку dist;
    • отправляет её на хостинг.
    1. Хостинг начинает раздавать новую версию приложения пользователям.

    Это и есть:

    • CI (Continuous Integration) — непрерывная интеграция кода в репозиторий;
    • CD (Continuous Delivery / Deployment) — автоматическая доставка результата на хостинг.

    🌿 GitHub Pages и GitHub Actions

    В рамках курса в качестве хостинга используется GitHub Pages — бесплатный статический хостинг от GitHub.

    Важно зафиксировать архитектуру:

    • GitHub (репозиторий) — хранит код;
    • GitHub Actions — CI/CD сервер;
    • GitHub Pages — хостинг.

    Это три разных сервиса, даже если они находятся на одном сайте.

    👨🏻‍🏫 Роль GitHub Actions

    GitHub Actions — это полноценный CI/CD сервер, который:

    • поднимает виртуальную машину;
    • делает git clone;
    • выполняет npm install;
    • запускает npm run build;
    • деплоит результат на GitHub Pages.

    Мы не билдим продакшен локально и не копируем файлы руками. Всё делает автоматизированный pipeline.

    🕹️ Активация GitHub Pagesles28-25

    Для запуска всего процесса необходимо:

    • зайти в настройки репозитория (Settings);
    • активировать GitHub Pages;
    • выбрать вариант деплоя через GitHub Actions.

    После этого:

    • GitHub понимает, что нужно использовать CI/CD;
    • появляется вкладка Actions, где будут отображаться все запуски pipeline;
    • деплой будет происходить автоматически при пуше.

    🔸 Итоговая картина

    • Git — точка входа для всего pipeline.
    • Build — превращает исходники в продакшен-файлы.
    • CI/CD сервер — автоматизирует процесс.
    • Хостинг — раздаёт результат пользователям.

    Так устроено 90% современных фронтенд-проектов — от небольших стартапов до BigTech.

    Дальше мы будем пошагово настраивать этот pipeline и видеть, как после каждого пуша появляется новая версия приложения в интернете.

    26. 🌿 GitHub Actions, workflows

    Чтобы активировать GitHub Actions как CI/CD-сервер, одного переключателя в интерфейсе недостаточно. Нужна явная инструкция: что именно GitHub должен делать при каждом обновлении репозитория. Эта инструкция хранится в проекте в виде workflow-файла.

    В корне репозитория создаём структуру:

    • .github/
    • workflows/

    Важно: у .github точка в начале обязательна — это часть имени папки.

    Внутри workflows создаём файл, например:

    • deploy.yml

    YAML — формат конфигурации (по смыслу близкий к JSON: ключи, значения, массивы). Его часто используют DevOps-инженеры для описания пайплайнов и инфраструктуры.

    deploy.yml
    name: Musicfun Deploy
     
    on:
      push:
        branches: [main]
     
    permissions:
      contents: read
      pages: write
      id-token: write
     
    jobs:
      deploy:
        runs-on: ubuntu-latest
     
        steps:
          - uses: actions/checkout@v4
          - uses: actions/setup-node@v4
            with:
              node-version: 20
          - run: npm install
          - run: npm run build
          - uses: actions/upload-pages-artifact@v3
            with:
              path: dist
          - uses: actions/deploy-pages@v4

    Workflow — сценарий, который можно запускать автоматически (по событиям) или вручную. В нашем случае он нужен для деплоя.

    🔸 Логика пайплайна: что происходит от кода до хостинга

    Наша цель — чтобы деплой запускался автоматически при событии push в ветку main.

    Цепочка выглядит так:

    1. Мы пишем код локально и делаем commit.
    2. Делаем push в удалённый репозиторий.
    3. GitHub фиксирует событие push и запускает workflow.
    4. GitHub Actions поднимает отдельную виртуальную машину (условно “одноразовый сервер”).
    5. На этой машине выполняются шаги пайплайна: установка зависимостей, сборка, публикация результата.
    6. Собранная версия попадает на хостинг (GitHub Pages) и становится доступной пользователям.

    Важно: GitHub Actions и GitHub Pages — разные сервисы, хотя настраиваются через один интерфейс GitHub.

    Workflow не выполняется внутри репозитория и не выполняется “на GitHub как на одном компьютере”.

    GitHub Actions выдаёт чистую среду (например, Ubuntu). В ней нужно явно выполнить всё необходимое:

    • получить исходники проекта (по сути — git clone);
    • подготовить Node.js нужной версии;
    • поставить зависимости;
    • собрать приложение;
    • передать результат в GitHub Pages.

    Поэтому в workflow описываются окружение и последовательность шагов.

    🔸 Структура workflow: событие → job → steps

    В workflow обычно фиксируются три уровня:

    • Когда запускаемся: например, push в main.
    • Какая работа выполняется: job (у нас одна, логично назвать deploy).
    • Какие шаги внутри job: steps, которые выполняются по очереди.
    ⚠️

    Термины “workflow” и “pipeline” часто используют почти как синонимы. Формально workflow — описание, а pipeline — процесс выполнения. На этом уровне разницы почти нет.

    🔸 Что делает Deploy workflow (по шагам)

    Ниже — смысл шагов без погружения в синтаксис.

    🔸 1) Checkout репозитория

    CI-машина должна получить ваш код. Это эквивалент “скачай репозиторий и открой проект”.

    🟩 2) Node.js нужной версии

    Фиксируем версию Node (например, 20), чтобы сборка была стабильной и одинаковой везде.

    🔸 3) Установка зависимостей

    Запускается установка пакетов — без этого проект не соберётся.

    🔸 4) Сборка (build)

    Запускается сборка, и на выходе появляется папка dist — готовый продакшен-бандл (HTML/CSS/JS), который можно раздавать пользователям.

    🔸 5) Загрузка артефактов

    Папка dist отправляется в специальное место, откуда GitHub Pages сможет её забрать.

    🌿 6) Деплой на GitHub Pages

    GitHub Pages забирает артефакты и публикует сайт. После этого новая версия становится доступна по публичному URL.

    🔸 Финальный шаг: commit + push запускают деплой

    После добавления workflow-файла в репозиторий остаётся стандартный путь:

    • закоммитить изменения,
    • запушить в main.

    Именно этот push становится триггером: GitHub Actions запускает workflow, выполняет шаги и деплоит приложение на GitHub Pages.

    26. 📌.env, переменные окружения

    Когда приложение уже оказалось на GitHub Pages, почти всегда всплывает первый «взрослый» момент: секреты.

    🔸 Ошибка, которую нельзя повторять

    Можно совершить типичную, но серьёзную ошибку: запушил API-key в репозиторий.

    Почему это плохо:

    • API-key обычно считается секретом и не должен быть публичным.
    • Если ключ окажется в открытом репозитории, его могут скопировать и использовать.
    • На продакшене бэкенд часто режет запросы или блокирует ключи, которые «светились» в публичном доступе.
    ⚠️

    Если вы тоже запушили ключ — не “удаляйте строку и успокойтесь”. Перегенерируйте ключ на стороне API, чтобы старый перестал работать.

    🔸 Два окружения: dev и prod

    У нас фактически два режима:

    • Dev-окружение: ключ нужен, чтобы удобно разрабатывать.
    • Prod-окружение: ключ либо не нужен, либо должен быть под контролем окружения, а не в коде.

    Для таких различий используют переменные окружения.

    🔸 Что такое.env

    .env — это файл с парами ключ=значение, без кавычек. Всё считается строкой.

    Особенность Vite:

    • Чтобы переменная читалась в приложении, нужен префикс VITE_.
    • Например, ключ для API оформляют как переменную окружения с VITE_....

    🌿 Важно про Git:.env не должен улетать в репозиторий

    Здесь есть два популярных подхода:

    1. Не коммитить .env вообще Тогда .env добавляют в .gitignore.

    2. Коммитить .env, но без секретов (часто на фронтенде так делают, но секреты тогда выносят иначе)

    Если вы добавили файл в отслеживание до того, как внесли его в .gitignore, он будет продолжать считаться отслеживаемым. Gitignore не “откатывает” историю.

    .gitignore
    .env
    .env
    VITE_API_KEY=XXX-XXX-XXX
    api.ts
    const apiKey = import.meta.env.VITE_API_KEY
    const headers: HeadersInit = {}
     
    if (apiKey) {
      headers["api-key"] = apiKey
    }
    //...

    🔸 Ситуация из жизни: файл уже попал в staging

    Типичный сценарий:

    • IDE предложила «добавить новый файл в Git» — вы согласились.
    • .env оказался в staged.
    • Потом вы добавили .env в .gitignore, но Git всё равно продолжает его видеть.

    Как это выглядит по смыслу:

    • staged — файл “готов к коммиту”;
    • modified — изменения “есть, но ещё не подготовлены”.

    Если .env попал в staged по ошибке — его нужно убрать из staged, иначе он улетит в commit.

    🔸 Чтение переменной окружения в приложении

    Дальше логика такая:

    • API-key больше не хардкодится в коде.
    • Он читается из переменной окружения.
    • Но в продакшене .env может не быть → значение будет undefined.

    И вот здесь важный момент: нельзя бездумно добавлять заголовок с пустым значением.

    Правильная идея на уровне алгоритма:

    • создаём объект headers;
    • если переменная окружения существует — добавляем api-key;
    • если нет — не добавляем этот header вообще.
    ⚠️

    Пустой заголовок с undefined — это не “всё равно”. Это может ломать запросы и давать неочевидные баги.

    Также это решение удобно тем, что:

    • логика вычисляется один раз при загрузке модуля;
    • значение ключа не меняется «по жизни» приложения;
    • значит, нет смысла собирать headers каждый раз заново.

    🌿 Повторный деплой через GitHub Actions

    После правок:

    • коммитим изменения;
    • пушим в main;
    • GitHub Actions автоматически запускает workflow деплоя.

    Дальше — вкладка Actions:

    • видим workflow;
    • проваливаемся внутрь;
    • видим job;
    • внутри job — шаги пайплайна.

    Если workflow упал:

    • смотрим на шаг, где ошибка;
    • читаем лог;
    • фиксим причину и пушим снова.

    🔸 Где найти ссылку на задеплоенный сайтles28-26

    Самый надёжный путь:

    • Repository → SettingsPages
    • Там будет сообщение о последнем деплое и ссылка Visit site.

    Итог: приложение доступно по домену GitHub Pages (поддомен GitHub), без покупки собственного домена.

    27. ⚡ Vite.config.ts, base

    После деплоя вы открываете сайт через Visit site, видите белую страницу, лезете в DevTools — и там 404 / ABORTED: скрипты не подгружаются.

    🔸 Почему так происходит

    У GitHub Pages адрес проекта обычно выглядит так:

    • домен GitHub (username.github.io)
    • плюс сегмент /<repo-name>/, потому что сайт живёт внутри репозитория, а не в корне домена.

    А вот собранный index.html по умолчанию часто ссылается на ассеты как на корневые:

    • /assets/index.js
    • /assets/index.css

    В результате браузер пытается загрузить ассеты с корня домена, а реальный корень вашего приложения — /<repo-name>/. Поэтому и получаются 404.

    ⚡ Фикс: указать base path в Vite

    Нужно объяснить Vite, что приложение будет жить не в /, а в /<repo-name>/. Для этого в vite.config задают базовый путь (base), равный имени репозитория.

    vite.config
    import { defineConfig } from "vite"
    import react from "@vitejs/plugin-react"
     
    // https://vite.dev/config/
    export default defineConfig({
      base: "react-from-zero-middle",
      plugins: [react()],
    })

    После этого:

    • вы коммитите изменения
    • пушите в main
    • GitHub Actions запускает workflow
    • происходит новый build и deploy
    • ссылки в index.html становятся корректными

    28. 📌 Domain для apihub.it-incubator.io

    Когда UI уже работает, следующая проблема проявляется в Network:

    • запросы к API улетают
    • в ответ прилетает ошибка вида Too many requests / domain is incorrect / ограничения по домену

    Смысл в том, что API может быть настроен так, что:

    • в dev запросы разрешены (localhost)
    • в prod домен должен быть явно добавлен в настройках API (allowlist), иначе запросы блокируются

    les28-27

    Поэтому в продакшене вы видите ошибки, пока не добавите ваш домен GitHub Pages в разрешённые.

    Боевой маршрут (React Путь Самурая: без альтернатив)

    Видеоурок - 30 видео из 32