13 - UI\UX шаблон List - Detail (список - детали)

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

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

Паттерн "Список и Детали"

Автор конспекта: Бадалова Елена

Практически все, с чем мы взаимодействуем в вебе, — это списки: список друзей, транзакций, фильмов, музыки, диалогов. Паттерн "Список и Детали" (List-Detail) — один из фундаментальных в UI-дизайне. Но зачем он нужен? Все просто: мы видим большой список, чтобы из него что-то выбрать и посмотреть детали. Сначала мы получаем общее представление с минимумом информации, а затем фокусируемся на чем-то одном.

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

Способы отображения деталей могут варьироваться: рядом со списком (двухколоночный макет), на отдельной странице (через переход по ссылке), под выбранным элементом (аккордеон), во всплывающем окне (модальное окно или pop-up).

list-detail

State и UI

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

Мир данных (State / Data Structures) — это структуры данных (переменные, массивы, объекты), которые полностью описывают текущее состояние приложения.

Мир отображения (Render Algorithm) — это то, что непосредственно занимается отрисовкой элементов на экране, формируя пользовательский интерфейс (UI).

📌

Любое изменение, которое должно быть отражено на экране, должно сначала произойти в стейте.

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

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

render-state

Задача

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

selectedTrack

Подготовим вёрстку

App.tsx
export function App() {
  /*...*/
 
  return (
    <div>
      {/*...*/}
 
      <div style={{ display: "flex", gap: "30px" }}>
        <ul>{/* Список треков */}</ul>
 
        {/*Детали конкретного выбранного трека*/}
        <div>
          <h3>Details</h3>
          {selectedTrackId === null ? "Track is not selected" : ""}
        </div>
      </div>
    </div>
  )
}

1. Получение деталей в локальном массиве

🔶 Сохранение выбранного трека во внешней переменной внутри цикла map во время рендеринга списка

Категорически не рекомендуется

App.tsx
export function App() {
  /*...*/
 
  // объявляем внешнюю переменную перед JSX
  let selectedTrack = null
 
  return (
    <div>
      {/*...*/}
 
      <div style={{ display: "flex", gap: "30px" }}>
        <ul>
          {tracks.map((track) => {
            if (track.id === selectedTrackId) {
              selectedTrack = track // текущий трек присваивается внешней переменной
            }
 
            return <li>{/*...*/}</li>
          })}
        </ul>
 
        {/*Детали конкретного выбранного трека*/}
        <div>
          <h3>Details</h3>
          {/*Когда `map` отработает, он за собой, как побочный эффект, оставит переменную `selectedTrack`. */}
          {/*А значит мы сможем ее использовать ниже.*/}
          {selectedTrackId === null ? "Track is not selected" : selectedTrack.attributes.title}
        </div>
      </div>
    </div>
  )
}

⚠️ Недостатки:

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

  2. Callback функция, передаваемая в .map(), не должна иметь побочных эффектов (т.е. изменять внешние переменные). Ее задача — только преобразование данных.

🔶 Вычисление данных до возврата JSX

Второй подход — это вычислять данные о выбранном треке прямо в момент рендеринга. Нам ведь нужен не просто ID, а весь объект трека. Мы можем легко найти его в массиве tracks с помощью метода find перед возвращением JSX.

App.tsx
let selectedTrack = tracks.find((track) => track.id === selectedTrackId)

Стрелочная функция, которую мы передаём в метод find(), называется функция-предикат.

📌

Функция-предикат – это функция, которая всегда возвращает логическое значение (true или false), проверяя некоторое условие или утверждение о переданных данных.

selectedTrack является производным от уже существующих данных (tracks и selectedId), и для его хранения не обязательно заводить дополнительный стейт.

Ключевые преимущества этого подхода:

  • Отсутствует дублирование данных. Вся информация о треках хранится только в одном месте — в массиве tracks.

  • Производные данные не могут "рассинхронизироваться". Переменная selectedTrack всегда будет актуальна, так как вычисляется заново при каждом рендере на основе текущего состояния.

  • Этот подход является нормальным. Однако он становится неприменимым в асинхронных сценариях.

🔶 Асинхронные данные и проблема UX

Сейчас мы рассмотрим версию, где при клике на элемент, мы будем сохранять не id выбранного трека, а сам объект выбранного трека (selectedTrack) в отдельной переменной состояния (useState). Т.е. мы будем хранить отдельно идентификатор и отдельно трек.

selectedTrack

App.tsx
export function App() {
    const [selectedTrackId, setSelectedTrackId] = useState(null)
    const [selectedTrack, setSelectedTrack] = useState(null)
 
    // ...
 
    let selectedTrack = tracks.find(track => track.id === selectedTrackId);
    // ...

Создание взаимосвязанных состояний (selectedTrackId и selectedTrack) увеличивает риск рассогласованности данных, если они меняются не синхронно.

Вызовем set функцию для изменения состояния (setSelectedTrack) напрямую внутри тела компонента App (в процессе рендеринга).

App.tsx
// ...
let selectedTrack = tracks.find((track) => track.id === selectedTrackId) // Вот здесь мы определили трек
 
setSelectedTrack(selectedTrack) // А здесь мы могли бы его засетать
// ...

Крайне некорректная реализация

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


Корректные места для асинхронных операций (изменения состояния, запросы на сервер)

  • Обработчики событий (Event Handlers) - Основное место для изменения состояния и выполнения запросов на сервер, которые инициируются действиями пользователя (например, кликом).

  • Хук useEffect - Для более сложных операций, запросов, выполняемых при монтировании/обновлении компонента, или при изменениях других состояний/пропсов.

Переносим логику в обработчики событий (onClick)

App.tsx
export function App() {
  /*...*/
 
  return (
    <div>
      <h1>Musicfun</h1>
      <button
        onClick={() => {
          setSelectedTrackId(null)
          setSelectedTrack(null)
        }}
      >
        Reset selection
      </button>
 
      <div style={{ display: "flex", gap: "30px" }}>
        <ul>
          {tracks.map((track) => {
            return (
              <li
                key={track.id}
                style={{
                  border: track.id === selectedTrackId ? "1px solid orange" : "",
                }}
              >
                <div
                  onClick={() => {
                    setSelectedTrackId(track.id)
                    setSelectedTrack(track)
                  }}
                >
                  {track.attributes.title}
                </div>
                <audio src={track.attributes.attachments[0].url} controls></audio>
              </li>
            )
          })}
        </ul>
      </div>
    </div>
  )
}

Ключевые преимущества этого подхода:

  • Нет необходимости в find внутри map, так как объект трека уже доступен в замыкании обработчика клика.
  • Если несколько set функций вызываются внутри одного обработчика события, React объединяет их, вызывая компонент App только один раз для перерисовки после всех изменений.

2. Получение деталей с сервера

В реальных приложениях сервер редко отдает все данные сразу. Детальная информация, как тексты песен (lyrics) или история сообщений в мессенджере, может быть очень объемной. Какова вероятность, что пользователь захочет увидеть тексты всех 1000 песен из списка? Загружать все это заранее — крайне неэффективно.

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

  • Первый запрос (при загрузке) - Получаем основной список треков (ID, название), но без текстов.

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

Идем в документацию API и находим данный endpoint, который возвращает детали трека по его ID.

endpoint

Запрос на получение деталей конкретного трека целесообразно делать в обработчике события onClick на элементе списка. Это логично, так как действие инициируется пользователем.

App.tsx
export function App() {
  /*...*/
 
  return (
    <div>
      {/*...*/}
 
      <div style={{ display: "flex", gap: "30px" }}>
        <ul>
          {tracks.map((track) => {
            return (
              <li
                key={track.id}
                style={{
                  border: track.id === selectedTrackId ? "1px solid orange" : "",
                }}
              >
                <div
                  onClick={() => {
                    setSelectedTrackId(track.id)
 
                    fetch(
                      "https://musicfun.it-incubator.app/api/1.0/playlists/tracks/" + track.id,
                      {
                        headers: { "api-key": "0e0d1907-125b-437f-bba3-488ac8d7xxx" },
                      },
                    )
                      .then((res) => res.json())
                      .then((json) => {
                        setSelectedTrack(json.data)
                      })
                  }}
                >
                  {track.attributes.title}
                </div>
                <audio src={track.attributes.attachments[0].url} controls></audio>
              </li>
            )
          })}
        </ul>
 
        <div>
          <h2>Details</h2>
          {selectedTrack === null ? (
            "Track is not selected"
          ) : (
            <div>
              <h3>{selectedTrack.attributes.title}</h3>
              <h4>Lyrics</h4>
              <p>{selectedTrack.attributes.lyrics ?? "no lyrics"}</p>
            </div>
          )}
        </div>
      </div>
    </div>
  )
}

Итоговый результат: мы кликаем по треку и в списке деталей видим текст песни (lyrics) 🚀

Проблема с отзывчивостью (UX)

Если избавиться от selectedTrackId, то при медленном интернете возникает серьёзная проблема с пользовательским опытом.

Когда пользователь кликает на трек:

  • Отправляется запрос на сервер.
  • Интерфейс "замирает". Ничего не происходит, пока не придёт ответ.
  • Только после получения ответа трек подсвечивается и одновременно появляются его детали.

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


Чтобы решить проблему UX, мы сознательно идём на архитектурное решение: разделить состояние и инициировать две последовательные перерисовки (рендера).

  • selectedTrackId - для мгновенного отклика UI (подсветка элемента).

  • selectedTrack для отложенного отображения полных данных, полученных с сервера.

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


🏠 Домашнее задание

Цель задания:

Изучить и реализовать UI/UX паттерн "Список-Детали" (List-Detail) на практике, используя API для получения списка задач и их детальной информации.

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

Задача 1

Отображение базовых деталей

  1. Добавь новый useState, в котором будет храниться вся задача целиком
tsx
const [selectedTask, setSelectedTask] = useState(null)
  1. При выборе задачи справа должен появляться блок с деталями. В блоке с деталями выведи заголовок задачи (title)
  2. Когда задача не выбрана, должен отображаться текст "Task is not selected"

Итоговый результат 🚀

res13-1

Задача 2

Загрузка детальной информации с сервера

  1. Реализуй загрузку детальной информации о задаче с сервера при клике на задачу
  2. Используй endpoint: GET /boards/{boardId}/tasks/{taskId} для получения полной информации
🤔
Внимательно подумай, откуда взять boardId
  1. Отобрази дополнительную информацию из детального запроса:
  • title - заголовок таски
  • boardTitle - заголовок доски в которой находится таска
  • description - описание таски. Если description отсутствует выведи no description

Итоговый результат 🚀. При клике на таску идёт запрос на сервер, приходит детальная информация и выводится в блоке деталей

res13-2

Задача 3 ⭐

⭐ Дополнительное задание со звёздочкой. Проделывай по желанию.

Улучшение UX

  1. Проблема отзывчивости: При медленном интернете пользователь не понимает, что клик произошёл
  • Реши эту проблему БЕЗ добавления нового состояния loading
  • Подсказка: подумай о последовательности обновления состояний
  1. Индикация загрузки: Когда детали задачи загружаются, покажи "Loading..." вместо старых данных
  • Используй только существующие состояния
  • Подумай о логических условиях отображения

Итоговый результат 🚀. Для того чтобы проверить в Network выбери 3G

res13-3


💡

Удачи в выполнении домашнего задания! Помни: цель не просто заставить код работать, а понять принципы и научиться писать качественный, масштабируемый код.

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

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