Я работаю старшим фронтенд-разработчиком в it-отделе одного из крупнейших федеральных застройщиков. Специфика разработки в такой непрофильной компании — сроки спускаемые сверху и вообще не имеющие корреляции с реальными ресурсами и возможностями команды. Именно поэтому мы работаем очень быстро, постоянно пытаясь получить (максимум результата)*3 за (минимум времени)/4.
В этих условиях мы делали большие интеграции с headless CMS Directus и непосредственно с бекендом, используя моковые данные на фронте.
Интеграции были большие и быстрые — и вот тут-то и стало видно, что большинство фронтенд-разработчиков не очень понимают, как подготовить интеграцию, чтобы потом было быстро и не больно заменять моки на реальные ответы. В этой статье пойдет речь о таких подходах на фронтенде,
Подготовка к интеграции
Представим ситуацию: бек не готов, фронту надо двигаться по задачам — принимаем решение делать верстку на моках, пока ждем бек. Наша задача - сверстать новые компоненты, добавить по возможности всю логику, сделать максимум подготовки к интеграции, протестировать фронтовую часть.
Что в этом случае делает фронтендер?
1. Самый простой случай — верстаем компоненты и встраиваем данные в верстку
Фронтендер берет макеты, верстает, встраивая данные строками/числами и т. д. непосредственно в верстку. Очень странно, но даже опытные разработчики используют такой метод.
Выглядит это так
// page.tsx
import React from 'react';
import { RootComponent } from '@/app/MockExample/1/_components/RootComponent';
export default async function MockExample() {
return (
<>
<h1>Отличное начало</h1>
<RootComponent />
<div>Но финал может быть грустным</div>
</>
);
}
// RootComponent.tsx
import { NewsList } from '../NewsList';
import styles from './RootComponent.module.scss';
export const RootComponent = () => {
return (
<div className={styles.RootComponentContainer}>
<h2>Новости</h2>
<div className={styles.dateWrapper}>
Дата обновления: <span className={styles.date}>20 сентября 2025</span>
</div>
<NewsList />
</div>
);
};
// NewsList.tsx
import { NewsItem } from '@/app/MockExample/2/_components/NewsItem';
import styles from './NewsList.module.scss';
export const NewsList = () => {
const data = new Array(5).fill(true);
return (
<div className={styles.Component1Container}>
<div>Новоости предоставлены агетством Rei</div>
{data.map((_, index) => (
<NewsItem key={'newsItem'+index} />
))}
<div>Список авторов:</div>
<ul>
{data.map((_, index) => (
<div className={styles.author} key={'author'+index}>
Киселев Д.К.
</div>
))}
</ul>
</div>
);
};
//NewsItem.tsx
import styles from './NewsItem.module.scss';
export const NewsItem = () => {
return (
<div className={styles.NewsItemContainer}>
<h3>Новость №1</h3>
<div className={styles.tags}>Hit!</div>
<h3>Убийство во Восточном экспрессе</h3>
<div className={styles.date}>1933</div>
<img src={'./mockImage'} alt={'Обложка'} />
<div>Комментариев: 5</div>
<div>Понравилось: 5</div>
</div>
);
};
А между тем, минусы у него очень большие:
при такой верстке никто не заморачивается делать различные виды данных — делают просто один вариант, соответственно все ошибки не показанных различий состояний останутся на пост-интеграцию
есть большие риски при интеграции пропустить какие-то поля и оставить их моками - в особенности если компоненты сложные и разбиты на более мелкие
поскольку данные разбросаны по компонентам, у фронтендера нет полной картины, какие данные понадобятся и в какие структуры их лучше организовать
нет готового ответа на вопрос бека, какие данные ему тут нужны. А этот ответ бывает очень полезен — т. к. в этом случае у вас есть готовый контракт, который вы отдаете беку. В большинстве случаев бек вернет вам именно его и это минимизирует вашу работу при интеграции.
Плюсов в этом подходе не вижу.
2. Данные заданы объектом в самих компонентах (и корневых, и дочерних)
В этом случае разработчик задает объекты/массивы данных тут же в компоненте и уже их использует в верстке.
// page.tsx
import React from 'react';
import { RootComponent } from '@/app/MockExample/2/_components/RootComponent';
export default async function MockExample() {
const data = {
title: 'Отличное начало',
comment: 'Но финал может быть грустным',
};
const { title, comment } = data;
return (
<>
<h1>{title}</h1>
<RootComponent />
<div>{comment}</div>
</>
);
}
// RootComponent.tsx
import { NewsList } from '../NewsList';
import styles from './RootComponent.module.scss';
const label = 'Дата обновления: ';
export const RootComponent = () => {
const data = {
title: 'Новости',
date: '20 сентября 2025', // частая ошибка ставить в моки конкретное представление даты, а не дату ISO
};
const { title, date } = data;
return (
<div className={styles.RootComponentContainer}>
<h2>{title}</h2>
<div className={styles.dateWrapper}>
{label}
<span className={styles.date}>{date}</span>
</div>
<NewsList />
</div>
);
};
// NewsList.tsx
import { NewsItem } from '@/app/MockExample/2/_components/NewsItem';
import styles from './NewsList.module.scss';
const label = 'Новости предоставлены агентством';
export const NewsList = () => {
const dataObject = {
source: 'Rei',
};
const data = new Array(5).fill(true);
const { source } = dataObject;
return (
<div className={styles.Component1Container}>
<div>
{label} {source}
</div>
{data.map((_, index) => (
<NewsItem key={'newsItem'+index} />
))}
<div>Список авторов:</div>
<ul>
{data.map((_, index) => (
<div className={styles.author} key={'author'+index}>
Киселев Д.К.
</div>
))}
</ul>
</div>
);
};
// NewsItem.tsx
import styles from './NewsItem.module.scss';
const commentLabel = 'Комментариев:';
const favoritesLabel = 'Понравилось:';
export const NewsItem = () => {
const data = {
title: ' Новость №1',
tag: 'Hit!',
heading: 'Убийство в Восточном экспрессе',
image: './mock.webp',
alt: 'Обложка',
commentCount: 5,
favoritesCount: 5,
};
const { title, tag, heading, image, commentCount, favoritesCount, alt } = data;
return (
<div className={styles.NewsItemContainer}>
<h3>{title}</h3>
<div className={styles.tags}>{tag}</div>
<h3>{heading}</h3>
<div className={styles.date}>1933</div>
<img src={image} alt={alt} />
<div>
{commentLabel} {commentCount}
</div>
<div>
{favoritesLabel} {favoritesCount}
</div>
</div>
);
};
Минусы
обычно, это также один объект — без различных состояний и комбинаций данных
при интеграции очень легко оставить замоканными какие-то дочерние компоненты, т. к. моки задаются на месте использования и никак не связаны
остается минус отсутствия понимания необходимой полной структуры данных и отсутствия будущего контракта
3. Данные заданы объектом в каком-то корневом компоненте, в дочерние компоненты данные прокинуты пропсами
// page.tsx
import React from 'react';
import { RootComponent } from '@/app/MockExample/3/_components/RootComponent';
export default async function MockExample() {
const data = {
title: 'Отличное начало',
comment: 'Но финал может быть грустным',
subTitle: 'Новости',
date: '20 сентября 2025',
source: 'Rei',
newsList: [
{
title: ' Новость №1',
tag: 'Hit!',
heading: 'Убийство в Восточном экспрессе',
image: './mock.webp',
alt: 'Обложка',
commentCount: 5,
favoritesCount: 5,
},
],
authorsList: [{ name: 'Киселев Д.К.' }],
};
const { title, comment, subTitle, date, source, newsList, authorsList } = data;
return (
<>
<h1>{title}</h1>
<RootComponent
title={subTitle}
date={date}
source={source}
newsList={newsList}
authorsList={authorsList}
/>
<div>{comment}</div>
</>
);
}
// RootComponent.tsx
import { NewsList } from '../NewsList';
import styles from './RootComponent.module.scss';
const label = 'Дата обновления: ';
type Props = {
source: string;
title: string;
date: string;
newsList: {
title: string;
tag: string;
heading: string;
image: string;
alt: string;
commentCount: number;
favoritesCount: number;
}[];
authorsList: { name: string }[];
};
export const RootComponent = ({ title, date, source, newsList, authorsList }: Props) => {
return (
<div className={styles.RootComponentContainer}>
<h2>{title}</h2>
<div className={styles.dateWrapper}>
{label}
<span className={styles.date}>{date}</span>
</div>
<NewsList source={source} newsList={newsList} authorsList={authorsList} />
</div>
);
};
// NewsList.tsx
import { NewsItem } from '../NewsItem';
import styles from './NewsList.module.scss';
const label = 'Новости предоставлены агентством';
const authorsLabel = 'Список авторов:';
type Props = {
source: string;
newsList: {
title: string;
tag: string;
heading: string;
image: string;
alt: string;
commentCount: number;
favoritesCount: number;
}[];
authorsList: { name: string }[];
};
export const NewsList = ({ newsList, authorsList, source }: Props) => {
return (
<div className={styles.Component1Container}>
<div>
{label} {source}
</div>
{newsList.map((newsItem, index) => (
<NewsItem key={'newsItem'+index} />
))}
<div>{authorsLabel}</div>
<ul>
{authorsList.map(({ name }, index) => (
<div className={styles.author} key={'author'+index}>
{name}
</div>
))}
</ul>
</div>
);
};
// NewsItem.tsx
import styles from './NewsItem.module.scss';
const commentLabel = 'Комментариев:';
const favoritesLabel = 'Понравилось:';
type Props = {
title: string;
tag: string;
heading: string;
image: string;
alt: string;
commentCount: number;
favoritesCount: number;
};
export const NewsItem = ({
title,
tag,
heading,
image,
commentCount,
favoritesCount,
alt,
}: Props) => {
return (
<div className={styles.NewsItemContainer}>
<h3>{title}</h3>
<div className={styles.tags}>{tag}</div>
<h3>{heading}</h3>
<div className={styles.date}>1933</div>
<img src={image} alt={alt} />
<div>
{commentLabel} {commentCount}
</div>
<div>
{favoritesLabel} {favoritesCount}
</div>
</div>
);
};
Плюсы
здесь уже гораздо сложнее пропустить поля при интеграции, т. к. исходный моковый объект вы удалите, когда начнете использовать полученные от бека данные, и тут же получите ошибку тайпскрипта.
Минусы
обычно это также один объект — без вариаций
остается минус отсутствия понимания необходимой структуры данных (т. к. один вариант, как правило полную картину состояний и полей не дает) и отсутствия будущего контракта
здесь добавляется обычно такой минус: в дочерних компонентах типы прописывают через примитивы, не привязывая типы к исходному компоненту. В этом случае при интеграции и при изменениях типов или названий полей, вы получаете необходимость поправить их во всей цепочке компонентов.
4. Создан массив моковых данных, отражающий различные состояния
В этом случае:
-
создаем отдельный файл для моковых данных
// mock.ts import { TMockExample, TNewsItem, TRootComponent } from '@/app/MockExample/4/types'; const baseNewsItem: TNewsItem = { title: 'Короткий заголовок', tag: 'Есть длинный тег ', heading: 'Убийство в Восточном экспрессе', image: './mock.webp', alt: 'Обложка', commentCount: 5, favoritesCount: 5, }; // моки типизируем export const mockNewsList: TNewsItem[] = [ baseNewsItem, { ...baseNewsItem, tag: undefined, // разные состояния объектов }, { ...baseNewsItem, image: undefined, // то же }, { ...baseNewsItem, alt: undefined, // то же }, { ...baseNewsItem, commentCount: undefined, // то же }, { ...baseNewsItem, favoritesCount: undefined, // то же }, { title: 'Очень длинный заголовок - реально длинный заголовок', heading: 'Убийство в Восточном экспрессе может быть значительно длиннее', }, // и здесь ]; export const rootComponentBase: TRootComponent = { title: 'Новости', date: '20 сентября 2025', news: { source: 'Rei', newsList: mockNewsList, authorsList: [ { name: 'Киселев Д.К.' }, { name: 'Киселев-Заболотный-Залесский Д.К.' }, { name: 'Киселев Д.К.' }, { name: 'Киселев-Заболотный-Залесский Д.К.' }, { name: 'Киселев Д.К.' }, { name: 'Киселев-Заболотный-Залесский Д.К.' }, { name: 'Киселев Д.К.' }, { name: 'Киселев-Заболотный-Залесский Д.К.' }, { name: 'Киселев Д.К.' }, { name: 'Киселев-Заболотный-Залесский Д.К.' }, ], }, }; export const rootComponentWithEmptyAuthorsList: TRootComponent = { title: 'Новости', date: '29 сентября 2025', news: { source: 'Interfax ', newsList: mockNewsList, }, }; export const mockExampleBase: TMockExample = { title: 'Отличное начало', comment: 'Но финал может быть грустным', rootComponentData: rootComponentBase, }; отражаем в объектах все возможные состояния
типизируем моки — в типах уже получаем полную картину для формулирования контракта

-
прокидываем моки в точку получения данных, если нам нужно вывести компоненты непосредственно в приложение (либо используем истории в сторибуке)
// page.tsx export default async function MockExample() { // здесь в дальнейшем будет получен ответ от API const { title, comment, rootComponentData } = mockExampleBase; //... } -
в дочерних компонентах прописываем типы на основе корневого (это справедливо для не переиспользуемых компонентов — уникальных для этой структуры. Конечно, если дочерние компоненты — ui или переиспользованы в других местах, типы там должны оставаться абстрактными)
// RootComponent.tsx type RootComponentProps = { rootComponentData: TMockExample['rootComponentData']; }; // NewsList.tsx type Props = { news: TMockExample['rootComponentData']['news']; }; -
создаем истории в storybook на основе каждого элемента массива моков — получаем в сторибуке полную картину состояний для тестировщика, да и для нас самих это очень полезно при разработке
// RootComponent.stories.tsx import type { Meta, StoryObj } from '@storybook/react'; import { rootComponentBase, rootComponentWithEmptyAuthorsList } from '@/app/MockExample/4/mock'; import { RootComponent, RootComponentProps } from './RootComponent'; const meta = { title: 'Example/RootComponent', component: RootComponent, } satisfies Meta<RootComponentProps>; export default meta; type Story = StoryObj<typeof meta>; export const RootComponentBase: Story = { args: { rootComponentData: rootComponentBase }, }; export const RootComponentWithEmptyNewsList: Story = { args: { rootComponentData: rootComponentWithEmptyAuthorsList }, }; //NewsItem.stories.tsx import type { Meta, StoryObj } from '@storybook/react'; import { mockNewsList } from '@/app/MockExample/4/mock'; import { NewsItem, NewsItemProps } from './NewsItem'; const meta = { title: 'Example/NewsItem', component: NewsItem, } satisfies Meta<NewsItemProps>; export default meta; type Story = StoryObj<typeof meta>; export const NewsItemBase: Story = { args: { newsItem: mockNewsList[0] }, }; export const NewsItemWithoutTag: Story = { args: { newsItem: mockNewsList[1] }, }; export const NewsItemWithoutImage: Story = { args: { newsItem: mockNewsList[2] }, }; export const NewsItemWithoutAlt: Story = { args: { newsItem: mockNewsList[3] }, }; export const NewsItemWithoutComments: Story = { args: { newsItem: mockNewsList[4] }, }; export const NewsItemWithoutFavorites: Story = { args: { newsItem: mockNewsList[5] }, }; export const NewsItemShort: Story = { args: { newsItem: mockNewsList[6] }, }; опционально, но очень полезно — по каждой истории сторибука автоматом формируется снэпшот/скриншот
Итого получается вот такие компоненты:
// page.tsx
import React from 'react';
import { RootComponent } from './_components/RootComponent';
import { mockExampleBase } from './mock';
export default async function MockExample() {
// здесь в дальнейшем будет получен ответ от API
const { title, comment, rootComponentData } = mockExampleBase;
return (
<>
<h1>{title}</h1>
<RootComponent rootComponentData={rootComponentData} />
<div>{comment}</div>
</>
);
}
// RootComponent.tsx
import { TMockExample } from '@/app/MockExample/4/types';
import { NewsList } from '../NewsList';
import styles from './RootComponent.module.scss';
const label = 'Дата обновления: ';
export type RootComponentProps = {
// типы наследуются от корневого элемента
rootComponentData: TMockExample['rootComponentData'];
};
export const RootComponent = ({ rootComponentData }: RootComponentProps) => {
const { title, date, news } = rootComponentData;
return (
<div className={styles.RootComponentContainer}>
<h2>{title}</h2>
<div className={styles.dateWrapper}>
{label}
<span className={styles.date}>{date}</span>
</div>
<NewsList news={news} />
</div>
);
};
// NewsList.tsx
import { TMockExample } from '@/app/MockExample/4/types';
import { NewsItem } from '../NewsItem';
import styles from './NewsList.module.scss';
const label = 'Новости предоставлены агентством';
const authorsLabel = 'Список авторов:';
type Props = {
// типы наследуются от корневого элемента
news: TMockExample['rootComponentData']['news'];
};
export const NewsList = ({ news }: Props) => {
const { newsList, authorsList, source } = news;
return (
<div className={styles.Component1Container}>
<div>
{label} {source}
</div>
{newsList.map((newsItem, index) => (
<NewsItem key={'newsItem'+index} newsItem={newsItem} />
))}
<div>{authorsLabel}</div>
<ul>
{authorsList?.map(({ name }, index) => (
<div className={styles.author} key={'author'+index}>
{name}
</div>
))}
</ul>
</div>
);
};
// NewsItem.tsx
import { TNewsItem } from '@/app/MockExample/4/types';
import styles from './NewsItem.module.scss';
const commentLabel = 'Комментариев:';
const favoritesLabel = 'Понравилось:';
export type NewsItemProps = { newsItem: TNewsItem };
export const NewsItem = ({ newsItem }: NewsItemProps) => {
const { title, tag, heading, image, commentCount, favoritesCount, alt } = newsItem;
return (
<div className={styles.NewsItemContainer}>
<h3>{title}</h3>
<div className={styles.tags}>{tag}</div>
<h3>{heading}</h3>
<div className={styles.date}>1933</div>
<img src={image} alt={alt} />
<div>
{commentLabel} {commentCount}
</div>
<div>
{favoritesLabel} {favoritesCount}
</div>
</div>
);
};
Плюсы
есть единая точка получения данных — в случае проблем, будем сначала искать там, а не по мелким компонентам
данные типизированы — можем сразу сформулировать контракт с готовыми структурами данных и их типами
легко поменять данные
легко поменять типы
покрыты и сверстаны все состояния
если есть снэпшоты/скриншоты - новые компоненты автоматически покрываются тестами на верстку
Минусы
выглядит, как-будто надо приложить много усилий. На самом деле — нет. Профиты покрытия состояний и ускорение при интеграции все покрывают.
Ну, и наконец интеграция
Отлично, мы пошли по четвертому варианту, подготовили моки, загрузили все в сторибук, при верстке увидели все состояния, тестировщик проверил верстку по сторибуку. Дальше отдали типы беку (бек сказал нам спасибо), наш эндпойнт готов — и, вуаля, начинаем интеграцию.
Делаем подключение к беку, типизируем ответ.
// page.tsx
import { RootComponent } from './_components/RootComponent';
export default async function MockExample() {
const mockExampleResponse = await getMockExample();
if (mockExampleResponse == null) {
return;
}
const { title, comment, rootComponentData } = mockExampleResponse;
return (
<>
<h1>{title}</h1>
<RootComponent rootComponentData={rootComponentData} />
<div>{comment}</div>
</>
);
}
// network/mockExample.ts
export const getMockExample = async () => {
return apiClient
.get<TMockExampleDTO>('http://localhost:3000/mock-example')
.then(({ data }) => mapMockExample(data))
.catch((error) => {
console.error('***** [getMockExample]', error);
return undefined;
});
};
И тут опять возникает такая штука: большинство фронтов берут и начинают смешивать типы бека и типы фронтенда, т. к. они весьма похожи в какой-то момент времени.
Делать этого не стоит, т. к. сущности все же разные и рано или поздно типы начнут расходиться и вы словите кучу проблем. Поэтому мы разделяем типы входящие (TMockExampleDTO) и внутренние (TMockExample), даже если они одинаковы.
// types
export type TMockExamplePageDTO = {
title: string;
comment?: string;
};
export type TNewsItemDTO = {
title: string;
tag?: string;
heading: string;
image?: string;
alt?: string;
commentCount?: number;
favoritesCount?: number;
};
export type TNewsListDTO = {
source: string;
newsList: TNewsItemDTO[];
authorsList?: { name: string }[];
};
export type TRootComponentDTO = {
title: string;
date: string;
news: TNewsListDTO;
};
export type TMockExampleDTO = TMockExamplePageDTO & { rootComponentData: TRootComponentDTO };
Также в момент получения данных почти всегда возникает необходимость какие-то данные слегка преобразовать, развернуть, поменять название поля на camelCase, что-то подставить по умолчанию и т. д. Не стоит этого делать где-то в компонентах, сделайте в точке получения — в маппере (адаптере). Тогда вы всегда будете знать, где искать и это не будет расползаться по компонентам.
// здесь на входе тип бека, на выходе тип фронтенда
function mapMockExample(response: TMockExampleDTO): TMockExample {
const {
rootComponentData: {
news: { newsList, ...newsRest },
...rootComponentDataRest
},
...rest
} = response;
const mappedNewsList = newsList.map(({ image, ...newsItemRest }) => ({
image: `${ASSETS_URL}${image}`,
...newsItemRest,
}));
return {
...rest,
rootComponentData: {
...rootComponentDataRest,
news: {
...newsRest,
newsList: mappedNewsList,
},
},
};
}
Поэтому обычной практикой у нас является установка адаптера после получения ответа от бека и как раз в маппере мы сопоставляем тип ответа и соответствующий ему фронтовый тип компонента. Это очень удобно, т. к. при изменениях бека, достаточно внести в тип бека изменения и тайпскрипт тут же вам скажет, что пора вносить изменения в адаптер. А т. к. в адаптере используется фронтовый тип корневого компонента, то мы сразу получаем полную картину необходимых изменений.
Приведу пример: бек присылает нам в ответе вместо heading - headingRenamed.
Меняем тип бека

export type TNewsItemDTO = {
title: string;
tag?: string;
headingRenamed: string; // heading -> headingRenamed
image?: string;
alt?: string;
commentCount?: number;
favoritesCount?: number;
};
Тайпскрипт сразу подсказывает нам, где появляется несоответствие типу фронтенда.

Теперь меняем фронтовый тип.

export type TNewsItem = {
title: string;
tag?: string;
headingRenamed: string; // здесь меняем
image?: string;
alt?: string;
commentCount?: number;
favoritesCount?: number;
};
И не руками, а автоматически меняются и пропсы вложенных компонентов и моки, благодаря нашей типизации.


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