Предисловие
Для вашего удобства текстовый курс перенесен на отдельную платформу. Ссылка для поступления: тык
Мы настоятельно рекомендуем пользоваться именно этой платформой по ходу курса. Ссылка на текущее занятие: тык
⚠️Прежде чем переходить к заданиям - обязательно зарегистрируйтесь на курс. Иначе вас закинет на общий поток и вы не сможете взаимодействовать с сверстниками во втором модуле курса. Это не смертельно и вполне решаемо, но тем не менее.
React: JSX, компоненты, пропсы и списки
Зачем нужен React
На пятом занятии мы построили каталог по схеме «состояние и рендер». Данные хранились в одном месте, функция render строила из них страницу, а обработчики меняли данные и вызывали рендер. Такой код проще поддерживать, чем код, который правит элементы страницы напрямую.
У нашей функции рендера были недостатки:
при каждом изменении она пересоздавала весь список через
innerHTML. Если пользователь в это время вводил текст в поле внутри списка, поле пересоздавалось, и курсор пропадал;разметка собиралась из строк, и опечатку в теге или в имени класса никто не проверял;
обработчики событий приходилось назначать отдельно от разметки, через делегирование;
чем больше становилась страница, тем длиннее и запутаннее становилась одна функция рендера.
React — библиотека для создания интерфейсов, которая решает эти задачи. Интерфейс в React собирают из компонентов. Компонент — функция, которая получает данные и возвращает описание того, как должен выглядеть кусок интерфейса. Когда данные меняются, React снова вызывает функцию, сравнивает новое описание с предыдущим и меняет на странице только то, что отличается.
Разработчик на React описывает, какой интерфейс должен получиться при текущих данных, а не последовательность действий для изменения страницы. Такой подход называют декларативным.
Во втором модуле мы напишем на React приложение ITAM Board: доску событий, идей и вопросов вашего потока. Кроме React, существуют и другие библиотеки с похожими идеями: Vue, Svelte, Angular. Понимание компонентов и состояния пригодится при работе с любой из них.
Почитать ещё
React: Быстрый старт
Дока: React и альтернативы
Проект на 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() и импортируйте его в фигурных скобках, как в примере выше.
Почитать ещё
React: Создание приложения на React
Vite: шаблоны проектов (англ.)
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:
{/* текст */}.
Если нарушить правило, например не закрыть тег, проект не соберётся, а редактор подчеркнёт ошибку.
Почитать ещё
React: Разметка в JSX
Условный рендеринг
Часто часть интерфейса нужно показывать только при определённом условии. Инструкцию 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.
Почитать ещё
React: Условный рендеринг
Компоненты
Компонент — функция, которая возвращает 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>
);
}
Пропсы только для чтения. Компонент не меняет объект, который ему передали, а только описывает интерфейс для этих данных. Если данные нужно изменить, это делает компонент, которому они принадлежат. Как ребёнок может попросить родителя об изменении, разберём в разделе о колбэках.
Почитать ещё
React: Передача пропсов компоненту
React: Использование TypeScript
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: Передача JSX как children
Обработчики событий
Обработчик события в 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 выводит сам.
Почитать ещё
React: Обработка событий
Колбэки в пропсах
Компонент может получить функцию в пропсах и вызвать её, когда что-то происходит. Так дочерний компонент сообщает родителю о действии пользователя.
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.
Почитать ещё
Vite: CSS Modules (англ.)
Списки
Чтобы показать массив данных, его превращают в массив элементов методом 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 вернёт пустой массив, и на странице ничего не появится. Пользователю стоит показать сообщение, например «Пока пусто».
Почитать ещё
React: Рендер списков
Ключи
В примерах из прошлого раздела у каждого элемента списка есть атрибут 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.
Почитать ещё
React: Рендер списков
Моковые данные
Пока приложение не подключено к серверу, данные для интерфейса берут из моков — заранее подготовленного массива в коде. Моки делают в той же форме, в какой данные будут приходить с сервера. Тогда на двенадцатом занятии мы заменим источник данных, а компоненты менять не придётся.
Сервер курса описывает карточку так. Здесь приведены поля, которые понадобятся на этом занятии:
// 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 с моковыми данными.
Почитать ещё
React: Мышление в стиле React