Я работаю старшим фронтенд-разработчиком в 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;
};

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

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

Комментарии (0)