К списку уроков

Урок 9. React: JSX, компоненты, пропсы и списки

Онлайн и в Б-734, 26 октября (понедельник), 15:00

Предисловие

Для вашего удобства текстовый курс перенесен на отдельную платформу. Ссылка для поступления: тык

Мы настоятельно рекомендуем пользоваться именно этой платформой по ходу курса. Ссылка на текущее занятие: тык

⚠️Прежде чем переходить к заданиям - обязательно зарегистрируйтесь на курс. Иначе вас закинет на общий поток и вы не сможете взаимодействовать с сверстниками во втором модуле курса. Это не смертельно и вполне решаемо, но тем не менее.

React: JSX, компоненты, пропсы и списки

Зачем нужен React

На пятом занятии мы построили каталог по схеме «состояние и рендер». Данные хранились в одном месте, функция render строила из них страницу, а обработчики меняли данные и вызывали рендер. Такой код проще поддерживать, чем код, который правит элементы страницы напрямую.

У нашей функции рендера были недостатки:

  • при каждом изменении она пересоздавала весь список через innerHTML. Если пользователь в это время вводил текст в поле внутри списка, поле пересоздавалось, и курсор пропадал;

  • разметка собиралась из строк, и опечатку в теге или в имени класса никто не проверял;

  • обработчики событий приходилось назначать отдельно от разметки, через делегирование;

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

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

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

Во втором модуле мы напишем на React приложение ITAM Board: доску событий, идей и вопросов вашего потока. Кроме React, существуют и другие библиотеки с похожими идеями: Vue, Svelte, Angular. Понимание компонентов и состояния пригодится при работе с любой из них.

Почитать ещё

Проект на React

Проект на React создают так же, как проект на восьмом занятии, но с шаблоном react-ts:

npm create vite@latest itam-board -- --template react-ts
cd itam-board
npm install
npm run dev

Структура проекта похожа на проект с TypeScript без фреймворка:

itam-board/
├── index.html        в нём есть <div id="root"> и подключение src/main.tsx
├── package.json      зависимости react и react-dom
├── vite.config.ts    подключает к Vite плагин для React
└── src/
    ├── main.tsx      точка входа: запускает React
    ├── App.tsx       корневой компонент приложения
    ├── App.css
    └── index.css

Файлы с компонентами имеют расширение .tsx: это TypeScript, в котором можно писать разметку.

Приложение запускается в файле main.tsx:

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import './index.css';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
);
  • createRoot получает элемент <div id="root"> из index.html и передаёт управление им React. Всё содержимое этого элемента React будет строить сам.

  • render(<App />) показывает внутри корневого элемента компонент App. Из него начинается всё приложение.

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

В шаблоне компонент App экспортируется по умолчанию. В курсе мы используем именованные экспорты, поэтому в своём проекте пишите export function App() и импортируйте его в фигурных скобках, как в примере выше.

Почитать ещё

JSX

Разметку в React описывают с помощью JSX — синтаксиса, который позволяет писать теги прямо в коде JavaScript.

const title = 'Сходить на хакатон';
const score = 5;

const card = (
  <article className="card">
    <h3>{title}</h3>
    <p>Счёт: {score > 0 ? `+${score}` : score}</p>
  </article>
);

JSX похож на HTML, но это выражение JavaScript. Переменная card хранит описание элемента, которое можно вернуть из функции, положить в массив или передать в другую функцию. При сборке компилятор превращает теги в вызовы функций React.

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

В фигурных скобках можно писать только выражения, то есть то, что даёт значение. Инструкции if, for и объявления переменных внутри JSX не работают. Нужные значения вычисляют заранее, до return, или используют выражения: тернарный оператор и методы массивов.

Текст, который подставляется через фигурные скобки, React экранирует. Если в title окажется строка <img src=x onerror="...">, на странице появится этот текст, а не картинка. Уязвимость XSS из пятого занятия через обычные подстановки в JSX невозможна.

Почитать ещё

Чем JSX отличается от HTML

JSX выглядит как HTML, но у него более строгие правила и несколько отличий.

<>
  <label htmlFor="title" className="field__label">
    Заголовок
  </label>
  <input id="title" type="text" readOnly tabIndex={0} />
  <img src="/favicon.svg" alt="" />
  <div style={{ padding: 16, backgroundColor: '#eef2ff' }}>
    {/* Комментарий в JSX */}
    Текст
  </div>
</>;
  • Атрибут className вместо class, а htmlFor вместо for. Слова class и for зарезервированы в JavaScript.

  • Имена атрибутов в camelCase: tabIndex, readOnly, onClick. Исключение — атрибуты aria-* и data-*, их пишут через дефис, как в HTML.

  • Все теги закрыты. Одиночные теги обязательно закрывают косой чертой: <img />, <input />, <br />.

  • Один корневой элемент. Выражение JSX должно возвращать один элемент. Если соседних элементов несколько, их оборачивают в общий родитель или во фрагмент <>...</>. Фрагмент группирует элементы, но не создаёт лишнего тега на странице.

  • Атрибут style принимает объект, а не строку. Имена свойств пишут в camelCase, а числа без единиц React считает пикселями. Двойные фигурные скобки — это фигурные скобки выражения JSX и фигурные скобки объекта внутри.

  • Числа и выражения передают в фигурных скобках: tabIndex={0}. Строки можно передать в кавычках: type="text".

  • Комментарии записывают как выражение с комментарием JavaScript: {/* текст */}.

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

Почитать ещё

Условный рендеринг

Часто часть интерфейса нужно показывать только при определённом условии. Инструкцию if внутри JSX написать нельзя, поэтому используют выражения.

<article className="card">
  {card.preview && <img src={card.preview} alt="" />}

  <h3>{card.title}</h3>

  <p>
    {card.author.name}
    {card.is_mine && ' (вы)'}
  </p>

  <p>{card.score > 0 ? `+${card.score}` : card.score}</p>

  {card.description ? <p>{card.description}</p> : <p className="muted">Без описания</p>}
</article>;
  • условие && элемент показывает элемент, только если условие истинно.

  • условие ? элемент : другойЭлемент выбирает один из двух вариантов.

React не показывает на странице значения null, undefined, true и false. Поэтому, если условие ложное, выражение false && <img /> ничего не выведет.

С числами нужно быть внимательнее. Выражение {count && <p>Комментариев: {count}</p>} при count === 0 вернёт 0, и React выведет на страницу цифру ноль. В условии с числом сравнивайте явно: {count > 0 && ...}.

Если условий много, выбор делают до return с помощью обычного if:

function CardStatus({ card }: { card: Card }) {
  if (card.column === 'accepted') {
    return <span className="status status--accepted">Принято</span>;
  }
  if (card.column === 'rejected') {
    return <span className="status status--rejected">Отклонено</span>;
  }
  return null;
}

Компонент, которому нечего показать, возвращает null.

Почитать ещё

Компоненты

Компонент — функция, которая возвращает JSX. Компоненты используют в разметке как теги.

function Header() {
  return (
    <header className="header">
      <a href="/" className="header__logo">ITAM Board</a>
    </header>
  );
}

function EmptyColumn() {
  return <p className="empty">Пока пусто</p>;
}

export function App() {
  return (
    <>
      <Header />
      <main>
        <EmptyColumn />
      </main>
    </>
  );
}
  • Имя компонента начинается с заглавной буквы. По регистру React отличает компоненты от обычных тегов: <header> — тег HTML, <Header> — компонент.

  • Компонент используют как тег: <Header />. React вызовет функцию Header и подставит её результат на место тега.

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

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

// src/components/Header/Header.tsx
export function Header() {
  return <header className="header">ITAM Board</header>;
}

// src/App.tsx
import { Header } from './components/Header/Header';

Компонент должен быть чистой функцией: при одних и тех же входных данных он всегда возвращает одинаковый JSX и не меняет ничего за своими пределами. Во время рендера нельзя отправлять запросы, менять внешние переменные и работать с DOM напрямую. Для таких действий в React есть отдельные механизмы, их мы разберём на следующем занятии.

Почитать ещё

Пропсы

Компонент получает данные через пропсы (props, от properties). Пропсы передают как атрибуты тега, а React собирает их в один объект и передаёт компоненту первым параметром. Этот параметр принято называть props:

import type { CardType } from '../../types';

const TYPE_LABELS: Record<CardType, string> = {
  event: 'Событие',
  idea: 'Идея',
  question: 'Вопрос',
};

type Props = {
  type: CardType;
};

export function TypeBadge(props: Props) {
  return <span className={`badge badge--${props.type}`}>{TYPE_LABELS[props.type]}</span>;
}
<TypeBadge type="idea" />
<TypeBadge type={card.type} />

Встретив <TypeBadge type="idea" />, React вызовет функцию TypeBadge с объектом { type: 'idea' }. Внутри компонента значение доступно как props.type, а тип объекта описывает тип Props.

Если пропсов несколько, писать props. перед каждым неудобно. Нужные поля достают из объекта деструктуризацией, как на четвёртом занятии:

export function TypeBadge(props: Props) {
  const { type } = props;
  return <span className={`badge badge--${type}`}>{TYPE_LABELS[type]}</span>;
}

Деструктуризацию можно записать прямо в параметрах функции. Это сокращённая запись того же самого:

export function TypeBadge({ type }: Props) {
  return <span className={`badge badge--${type}`}>{TYPE_LABELS[type]}</span>;
}

Запись { type }: Props значит: первый параметр имеет тип Props, и из него сразу достаётся поле type. Переменной props в такой функции нет. Все три варианта TypeBadge работают одинаково. В курсе и в большинстве проектов на React пишут последний: по первой строке функции видно, какие пропсы нужны компоненту.

  • Строки передают в кавычках, всё остальное — в фигурных скобках: числа, переменные, объекты, функции.

  • TypeScript проверяет, что переданы все обязательные пропсы нужных типов. <TypeBadge type="task" /> не соберётся, потому что 'task' не входит в CardType.

  • Record<CardType, string> — тип объекта, у которого ключи — все значения CardType, а значения — строки. Если забыть подпись для одного из типов, TypeScript сообщит об ошибке.

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

type Props = {
  name: string;
  size?: number;
};

export function Avatar({ name, size = 40 }: Props) {
  return (
    <span className="avatar" style={{ width: size, height: size }}>
      {name[0]}
    </span>
  );
}

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

Почитать ещё

children

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

import type { ReactNode } from 'react';

type Props = {
  title: string;
  children: ReactNode;
};

export function Section({ title, children }: Props) {
  return (
    <section className="section">
      <h2 className="section__title">{title}</h2>
      {children}
    </section>
  );
}
<Section title="Идеи">
  <p>Здесь будут карточки с идеями.</p>
  <button type="button">Добавить идею</button>
</Section>;

Абзац и кнопка из примера попадут в children и появятся внутри <section> после заголовка.

  • Тип ReactNode описывает всё, что React умеет показать: элементы, строки, числа, массивы элементов, null.

  • С помощью children делают компоненты-обёртки: модальное окно, карточку, раздел страницы. Обёртка отвечает за рамку и оформление, а содержимое передаёт тот, кто её использует.

Компонент может принимать все стандартные атрибуты HTML-элемента. Так в ITAM Board устроена кнопка:

import type { ComponentProps } from 'react';

type Props = ComponentProps<'button'> & {
  variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
};

export function Button({ variant = 'secondary', type = 'button', className, ...props }: Props) {
  return <button type={type} className={cn(styles.button, styles[variant], className)} {...props} />;
}
  • ComponentProps<'button'> — тип всех атрибутов обычной кнопки: onClick, disabled, children и других. Знак & добавляет к ним проп variant.

  • ...props в параметрах собирает все пропсы, которые не были перечислены, в объект.

  • {...props} в JSX передаёт все эти пропсы элементу <button>. Благодаря этому <Button disabled onClick={save}>Сохранить</Button> работает без отдельного описания каждого атрибута.

  • type = 'button' по умолчанию нужен, чтобы кнопка внутри формы случайно не отправляла её.

Почитать ещё

Обработчики событий

Обработчик события в React передают атрибутом, имя которого начинается с on: onClick, onChange, onSubmit, onKeyDown.

export function LikeButton() {
  function handleClick() {
    console.log('Нажали на кнопку');
  }

  return (
    <button type="button" onClick={handleClick}>
      Нравится
    </button>
  );
}

В атрибут передают саму функцию, без вызова: onClick={handleClick}. React вызовет её, когда произойдёт событие.

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

<button type="button" onClick={() => onOpen(card.id)}>
  Открыть
</button>;

Частая ошибка — записать onClick={onOpen(card.id)}. Такая запись вызывает функцию во время рендера, а в onClick попадает её результат. Функция сработает при каждом рендере компонента, а при нажатии ничего не произойдёт.

Обработчик получает объект события. В TypeScript его тип зависит от события и элемента:

import type { ChangeEvent, MouseEvent } from 'react';

function handleChange(event: ChangeEvent<HTMLInputElement>) {
  console.log(event.target.value);
}

function handleBackdropMouseDown(event: MouseEvent<HTMLDivElement>) {
  if (event.target === event.currentTarget) {
    console.log('Нажали на само затемнение, а не на окно');
  }
}

Объект события в React работает так же, как в браузере: у него есть target, currentTarget, preventDefault() и key для событий клавиатуры. Если обработчик написан прямо в атрибуте, тип события TypeScript выводит сам.

Почитать ещё

Колбэки в пропсах

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

type Props = {
  card: Card;
  onOpen: (cardId: string) => void;
};

export function CardPreview({ card, onOpen }: Props) {
  return (
    <button type="button" className={styles.card} onClick={() => onOpen(card.id)}>
      <TypeBadge type={card.type} />
      <span className={styles.title}>{card.title}</span>
    </button>
  );
}
export function App() {
  function handleOpenCard(cardId: string) {
    console.log('Открыть карточку', cardId);
  }

  return <CardPreview card={mockCards[0]} onOpen={handleOpenCard} />;
}

Компонент CardPreview не знает, что произойдёт после нажатия: откроется модальное окно, страница или ничего. Он только сообщает, какую карточку выбрал пользователь. Решение принимает родитель. Благодаря этому CardPreview можно использовать и на доске, и на странице пользователя, где нажатие обрабатывается по-своему.

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

Имена пропсов-колбэков начинают с on: onOpen, onClose, onDone. Имена функций-обработчиков внутри компонента начинают с handle: handleOpenCard, handleSubmit.

Почитать ещё

Стили компонентов: CSS Modules

В проекте с десятками компонентов имена классов легко совпадают: класс .title может понадобиться и карточке, и модальному окну, и странице. Если стили лежат в общих файлах, правила одного компонента начинают действовать на другой.

CSS Modules решают эту проблему. Файл стилей называют с суффиксом .module.css и кладут рядом с компонентом. Классы из такого файла импортируют как объект:

/* CardPreview.module.css */
.card {
  display: grid;
  gap: 8px;
  padding: 12px;
  border-radius: 12px;
  background: white;
}

.title {
  font-weight: 600;
}
// CardPreview.tsx
import styles from './CardPreview.module.css';

export function CardPreview({ card }: Props) {
  return (
    <article className={styles.card}>
      <h3 className={styles.title}>{card.title}</h3>
    </article>
  );
}

При сборке Vite заменяет имена классов на уникальные, например .card превращается в ._card_x7f2a_1. Класс title в стилях карточки и класс title в стилях модального окна станут разными классами и не будут мешать друг другу. Внутри файла стили пишут как обычный CSS.

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

// src/lib/cn.ts
export function cn(...classes: (string | false | null | undefined)[]) {
  return classes.filter(Boolean).join(' ');
}
<span className={cn(styles.score, card.my_vote === 'up' && styles.up)}>{card.score}</span>;

cn отбрасывает ложные значения и склеивает остальные классы через пробел. Если голос не 'up', у элемента будет только класс score.

Общие стили всего приложения — шрифт, цвета, box-sizing — остаются в обычном файле index.css, который подключается в main.tsx.

Почитать ещё

Списки

Чтобы показать массив данных, его превращают в массив элементов методом map прямо в JSX.

type Props = {
  cards: Card[];
  onOpenCard: (cardId: string) => void;
};

export function CardList({ cards, onOpenCard }: Props) {
  return (
    <div className={styles.list}>
      {cards.map((card) => (
        <CardPreview key={card.id} card={card} onOpen={onOpenCard} />
      ))}
      {cards.length === 0 && <p className={styles.empty}>Пока пусто</p>}
    </div>
  );
}

map возвращает массив элементов JSX, и React умеет показывать массивы. Это тот же приём, что products.map(...).join('') на пятом занятии, только без строк и без join.

Если перед выводом данные нужно отфильтровать или отсортировать, это делают обычными методами массивов до return или прямо в цепочке:

const myCards = cards.filter((card) => card.is_mine);

return (
  <ul>
    {myCards.map((card) => (
      <li key={card.id}>{card.title}</li>
    ))}
  </ul>
);

Не забывайте о пустом состоянии. Если массив пуст, map вернёт пустой массив, и на странице ничего не появится. Пользователю стоит показать сообщение, например «Пока пусто».

Почитать ещё

Ключи

В примерах из прошлого раздела у каждого элемента списка есть атрибут key. Это специальный проп, по которому React отличает элементы списка друг от друга.

Когда список меняется, React сравнивает новый массив элементов с предыдущим. Если ключей нет, React сопоставляет элементы по позиции: первый с первым, второй со вторым. Если добавить карточку в начало списка, каждая карточка окажется на новой позиции, и React решит, что изменились все элементы. Хуже того, если у элемента было своё внутреннее состояние, например введённый в поле текст, это состояние останется на позиции и окажется у соседней карточки.

С ключами React находит каждый элемент по key и понимает, что старые карточки не изменились, а в начале появилась новая.

Правила для ключей:

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

  • ключ не меняется между рендерами: у одной и той же карточки всегда один и тот же ключ;

  • в качестве ключа используют идентификатор из данных, например card.id;

  • индекс элемента из map не подходит для списков, которые сортируют, фильтруют или в которые добавляют элементы. Индекс привязан к позиции, а не к данным;

  • key не попадает в пропсы компонента. Если компоненту нужен идентификатор, его передают отдельным пропсом.

Если ключ забыть, React выведет в консоль предупреждение Each child in a list should have a unique "key" prop. Это предупреждение указывает на реальную проблему, и его стоит исправлять сразу.

В примере ниже у элементов списка нет ключей. Запустите его и посмотрите на предупреждение в консоли под результатом.

const cards = [
  { id: 'card-1', title: 'Сходить вместе на хакатон' },
  { id: 'card-2', title: 'Воркшоп по Figma' },
];

export function App() {
  return (
    <ul>
      {cards.map((card) => (
        <li>{card.title}</li>
      ))}
    </ul>
  );
}

Чтобы предупреждение пропало, элементу <li> передают ключ: <li key={card.id}>.

Почитать ещё

Раскладываем карточки по колонкам

На доске ITAM Board пять колонок: «События», «Идеи», «Вопросы», «Принято», «Отклонено». Каждая карточка попадает в колонку по своему полю column. Разберём, как устроен такой вывод.

Сначала данные: порядок и названия колонок хранят в константе, а раскладывают карточки отдельной функцией.

export const COLUMNS: { id: CardColumn; title: string }[] = [
  { id: 'event', title: 'События' },
  { id: 'idea', title: 'Идеи' },
  { id: 'question', title: 'Вопросы' },
  { id: 'accepted', title: 'Принято' },
  { id: 'rejected', title: 'Отклонено' },
];

export function groupByColumn(cards: Card[]) {
  const groups: Record<CardColumn, Card[]> = { event: [], idea: [], question: [], accepted: [], rejected: [] };
  for (const card of cards) {
    groups[card.column].push(card);
  }
  return groups;
}

Функция groupByColumn не зависит от React: она получает массив и возвращает объект, в котором для каждой колонки лежит свой массив карточек. Такие вычисления держат отдельно от компонентов, чтобы компонент оставался коротким.

Затем компонент: два вложенных map, по колонкам и по карточкам внутри колонки.

export function BoardColumns({ cards, onOpenCard }: Props) {
  const groups = groupByColumn(cards);

  return (
    <div className={styles.board}>
      {COLUMNS.map((column) => {
        const columnCards = groups[column.id];
        return (
          <section key={column.id} className={styles.column}>
            <h2 className={styles.title}>
              {column.title}
              <span className={styles.count}>{columnCards.length}</span>
            </h2>
            <div className={styles.list}>
              {columnCards.map((card) => (
                <CardPreview key={card.id} card={card} onOpen={onOpenCard} />
              ))}
              {columnCards.length === 0 && <p className={styles.empty}>Пока пусто</p>}
            </div>
          </section>
        );
      })}
    </div>
  );
}

У колонки ключ column.id, у карточки — card.id. Колбэк внешнего map записан с фигурными скобками и return, потому что перед разметкой нужно вычислить columnCards.

Почитать ещё

Моковые данные

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

Сервер курса описывает карточку так. Здесь приведены поля, которые понадобятся на этом занятии:

// src/types.ts
export type CardType = 'event' | 'idea' | 'question';
export type CardColumn = CardType | 'accepted' | 'rejected';
export type VoteValue = 'up' | 'down';

export type User = {
  id: string;
  name: string;
  avatar_url: string | null;
};

export type Card = {
  id: string;
  title: string;
  type: CardType;
  description: string | null;
  preview: string | null;
  date: number | null;
  column: CardColumn;
  score: number;
  comments_count: number;
  my_vote: VoteValue | null;
  is_mine: boolean;
  author: User;
  created_at: number;
};
  • Поля description, preview и date могут быть null: у карточки может не быть описания, картинки или даты события.

  • Даты сервер присылает в виде Unix-времени: числа секунд, прошедших с 1 января 1970 года. Чтобы показать такую дату, её умножают на 1000 и передают в new Date.

  • Поле column вычисляет сервер: новая идея лежит в колонке idea, а идея, набравшая достаточно голосов, переезжает в accepted.

Готовый массив моков. Скопируйте файл в проект целиком, писать свои моки не нужно:

// src/mock-cards.ts
import type { Card, User } from './types';

const me: User = { id: 'user-1', name: 'Алексей Иванов', avatar_url: null };
const anna: User = { id: 'user-2', name: 'Анна Смирнова', avatar_url: null };
const ivan: User = { id: 'user-3', name: 'Иван Петров', avatar_url: 'https://picsum.photos/seed/ivan/96/96' };
const maria: User = { id: 'user-4', name: 'Мария Кузнецова', avatar_url: 'https://picsum.photos/seed/maria/96/96' };

export const mockCards: Card[] = [
  {
    id: 'card-1',
    title: 'Сходить вместе на хакатон',
    type: 'event',
    description: 'Собираем команду из четырёх человек: два фронтендера, бэкендер и дизайнер.',
    preview: 'https://picsum.photos/seed/hackathon/400/200',
    date: 1791648000,
    column: 'event',
    score: 4,
    comments_count: 3,
    my_vote: 'up',
    is_mine: false,
    author: anna,
    created_at: 1788858000,
  },
  {
    id: 'card-2',
    title: 'Воркшоп по Figma для разработчиков',
    type: 'event',
    description: 'Разберём, как брать из макета отступы, цвета и шрифты.',
    preview: null,
    date: 1792078200,
    column: 'event',
    score: 2,
    comments_count: 0,
    my_vote: null,
    is_mine: false,
    author: ivan,
    created_at: 1789197300,
  },
  {
    id: 'card-3',
    title: 'Сделать тёмную тему доски',
    type: 'idea',
    description: 'Вечером на светлой доске тяжело сидеть подолгу.',
    preview: 'https://picsum.photos/seed/dark-theme/400/200',
    date: null,
    column: 'idea',
    score: 3,
    comments_count: 5,
    my_vote: 'up',
    is_mine: false,
    author: maria,
    created_at: 1788460800,
  },
  {
    id: 'card-4',
    title: 'Разбирать домашние задания на созвоне по пятницам',
    type: 'idea',
    description: 'Полчаса на типичные ошибки из практик недели.',
    preview: null,
    date: null,
    column: 'idea',
    score: 1,
    comments_count: 1,
    my_vote: null,
    is_mine: true,
    author: me,
    created_at: 1789453800,
  },
  {
    id: 'card-5',
    title: 'Перенести все занятия на восемь утра',
    type: 'idea',
    description: 'Зато вечера будут свободны.',
    preview: null,
    date: null,
    column: 'idea',
    score: -4,
    comments_count: 7,
    my_vote: 'down',
    is_mine: false,
    author: ivan,
    created_at: 1789125600,
  },
  {
    id: 'card-6',
    title: 'Как готовиться к собеседованию на стажировку?',
    type: 'question',
    description: null,
    preview: null,
    date: null,
    column: 'question',
    score: 0,
    comments_count: 4,
    my_vote: null,
    is_mine: false,
    author: anna,
    created_at: 1789571100,
  },
  {
    id: 'card-7',
    title: 'Где посмотреть примеры хороших README?',
    type: 'question',
    description: 'Хочу к концу курса оформить README для ITAM Board.',
    preview: null,
    date: null,
    column: 'question',
    score: 2,
    comments_count: 0,
    my_vote: null,
    is_mine: true,
    author: me,
    created_at: 1789718400,
  },
  {
    id: 'card-8',
    title: 'Завести общий чат потока в Telegram',
    type: 'idea',
    description: 'Туда удобно скидывать ссылки и вопросы между занятиями.',
    preview: null,
    date: null,
    column: 'accepted',
    score: 7,
    comments_count: 6,
    my_vote: 'up',
    is_mine: false,
    author: maria,
    created_at: 1788282000,
  },
  {
    id: 'card-9',
    title: 'Встреча с выпускниками ITAM',
    type: 'event',
    description: 'Выпускники расскажут, как искали первую работу во фронтенде.',
    preview: 'https://picsum.photos/seed/meetup/400/200',
    date: 1792850400,
    column: 'accepted',
    score: 5,
    comments_count: 2,
    my_vote: null,
    is_mine: false,
    author: anna,
    created_at: 1787924700,
  },
];

В моках есть все случаи, которые должна уметь показать доска: карточки всех трёх типов, ваши карточки с is_mine: true, карточка без картинки и описания, отрицательный счёт, ваши голоса «за» и «против», принятые карточки и пустая колонка «Отклонено». Принятая карточка сохраняет свой тип: в column у неё 'accepted', а в type — 'idea' или 'event'.

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

Почитать ещё

Как разделить интерфейс на компоненты

Работа над интерфейсом на React начинается с разбиения макета на компоненты. Посмотрите на страницу доски и выделите части, у каждой из которых одна задача.

App
├── Header                 логотип и текущий пользователь
└── BoardColumns           пять колонок с карточками
    └── CardPreview        одна карточка в колонке
        └── TypeBadge      плашка с типом карточки

Подсказки, как разделять интерфейс:

  • Одна задача — один компонент. CardPreview показывает одну карточку, BoardColumns раскладывает карточки по колонкам. Если описание компонента требует союза «и», его стоит разделить.

  • Повторяющиеся части — отдельные компоненты. Плашка с типом встречается и в превью карточки, и в подробностях, поэтому TypeBadge вынесен отдельно.

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

  • Не дробите слишком рано. Если кусок разметки используется один раз и понятен без названия, оставьте его в родительском компоненте. Выносите его, когда файл перестаёт помещаться на экран или кусок понадобился во втором месте.

Удобно строить интерфейс снизу вверх: сначала мелкие компоненты, например TypeBadge и CardPreview, затем компоненты, которые их используют. Каждый компонент можно проверить отдельно, показав его в App с моковыми данными.

Почитать ещё

Задания