Я работаю старшим фронтенд-разработчиком в 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; };
И не руками, а автоматически меняются и пропсы вложенных компонентов и моки, благодаря нашей типизации.


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