React-компоненты вашей CMS не должны сами загружать данные

headless-cms

Рано или поздно каждая headless CMS выпускает набор компонентов для React, и большинство из них устроены одинаково: оберните приложение в провайдер, вставьте <BlogList />, и компонент сам загрузит посты. Демо получается приятным. И это было разумное решение, когда каждое React-приложение было одностраничным.

Сейчас это неверное решение. Когда есть React Server Components, лоадеры маршрутов в TanStack Start и React Router, а серверный рендеринг включён по умолчанию в каждом серьёзном фреймворке, компонент, который сам загружает данные, воюет с фреймворком, в котором работает. Я только что выпустил для Kookee компоненты блога, журнала изменений, центра помощи и дорожной карты и построил их на обратном правиле. Ниже — почему, и шесть правил, которые из этого вышли.

Что ломается, когда компонент сам загружает данные

Возьмём классическую схему:

tsx
<CmsProvider apiKey="…">
  <BlogList limit={10} />
</CmsProvider>

BlogList вызывает хук, хук загружает данные в эффекте, и список рендерится, когда данные пришли. Отсюда следуют четыре вещи, и ни одна не радует.

Контента нет в HTML. Эффекты не выполняются на сервере. В первом HTML, который получает посетитель или поисковый робот, на месте постов спиннер. Наборы обходят это собственным шагом предзагрузки, а это та же загрузка данных из фреймворка, только изобретённая заново внутри компонента.

Всё становится клиентским компонентом. Компонент, который вызывает хуки, должен работать в браузере, поэтому весь список, его карточки и разметка уходят в браузер как JavaScript — ради контента, который после рендера не меняется.

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

Контент идёт по сети дважды. В Next.js App Router каждый проп клиентского компонента сериализуется в страницу, чтобы React мог гидратировать компонент. Передайте клиентскому компоненту целый пост, и текст статьи придёт один раз как HTML и ещё раз внутри данных страницы.

Правило 1: принимать данные через пропсы

Компонент должен быть функцией от данных. Ему незачем знать, откуда данные пришли.

tsx
// app/blog/page.tsx, серверный компонент
const { data } = await kookee.blog.list({ limit: 12 });

return <BlogPostList posts={data} href="/blog/{slug}" />;

Загрузка происходит там, где её ждёт ваш фреймворк: в серверном компоненте, в лоадере или, в одностраничном приложении, в хуке, чей data вы передаёте напрямую. Один и тот же компонент работает во всех трёх случаях. Ему не нужен провайдер, у него нет своего состояния загрузки, а кеширование — то, которое уже есть в вашем фреймворке.

Заодно такой компонент легко тестировать. Дайте ему массив и посмотрите на разметку.

Правило 2: по умолчанию сервер, клиент — только там, где нужен браузер

Большая часть контентной страницы статична: списки, карточки, статьи, хлебные крошки, шапки. Браузер нужен лишь малой части. В блоге это кнопки реакций и оглавление, которое подсвечивает читаемый раздел. В центре помощи — строка поиска и голосование «Была ли эта статья полезной?».

Поэтому у каждого раздела с интерактивными частями два пути импорта:

  • @kookee/react/help рендерится на сервере, без состояния и без браузерных API;

  • @kookee/react/help/client, как и другие пути /client, содержит интерактивные части с пометкой 'use client'.

С серверными компонентами страница рендерит почти всё на сервере и гидратирует только те немногие острова, которым это нужно.

Это разделение не просто описано в документации: его проверяет сборка. Проверка запускается на публикуемых файлах и падает, если серверная точка входа создаёт React-контекст, помечена 'use client' или импортирует хук, который React не экспортирует для серверных компонентов. Там React экспортирует всего несколько хуков — useMemo, useCallback, useId и useDebugValue, — и только эти четыре проверка пропускает. Та же проверка падает на любой точке входа компонентов, которая импортирует что-то кроме React и SDK или весит больше 10 КБ в gzip.

Правило 3: клиентские компоненты принимают примитивы

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

Кнопки реакций не принимают пост. Они принимают id поста и счётчики:

tsx
<Reactions entryId={post.id} reactions={post.reactions} config={kookeeConfig} />

Оглавление не принимает текст статьи, чтобы разобрать его в браузере. Заголовки извлекает сервер, и клиентский компонент получает только их:

tsx
<TrackedTableOfContents headings={extractHeadings(post.contentHtml)} offset={64} resetKey={post.id} />

Текст длинной статьи остаётся в HTML, где ему и место, а для гидратации передаются несколько id, счётчиков и заголовков.

Правило 4: фильтры и пагинация — это ссылки

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

У фильтра по тегам на ссылках нет ни одной из них:

tsx
<BlogTagFilter tags={tags} activeTagSlug={tag} href="/blog?tag={tagSlug}" allHref="/blog" showCounts />
<Pagination page={page} totalPages={totalPages} href={(n) => (n === 1 ? '/blog' : `/blog?page=${n}`)} />

Страница рендерится заново для нового URL, а серверный компонент делает это в любом случае. Поиск по блогу следует тому же правилу: форма с GET — это пять строк HTML, поэтому отдельный компонент для неё я не выпускал. Пагинация оказалась важна не только ради удобства, и почему — я разобрал в отдельном посте о пагинации блога, по которой могут пройти поисковики.

Правило 5: поведение — в хуках, разметка — по желанию

Каждый интерактивный компонент — тонкий слой разметки над headless-хуком: useReactions, useActiveHeading, useWhatsNew, useHelpSearchBox. Хук отвечает за состояние, запросы, оптимистичные обновления и пограничные случаи. Компонент — лишь один из способов всё это отрисовать.

Если моя разметка не подходит к вашему дизайну, оставьте поведение и напишите свою:

tsx
const { counts, mine, toggle, isPending } = useReactions({ entryId, reactions, config });

То же с логикой в серверных компонентах. Извлечение заголовков, окно пагинации и группировка дорожной карты — обычные функции (extractHeadings, getPaginationItems, groupRoadmapPosts), которые можно вызвать из своей разметки.

Правило 6: контракт стилей — это API

Набор компонентов полезен ровно настолько, насколько его можно настроить, а настраивать безопасно, только когда вы знаете, что не поменяется у вас под ногами. Поэтому я отнёсся ко всему, что касается стилей, как к публичному API с теми же гарантиями, что и у пропсов:

  • CSS-переменные задают тему: --kookee-primary, --kookee-radius, по цвету на каждый тип записи журнала изменений.

  • Стабильные имена классов и слоты. У каждого элемента есть класс, и проп classNames дотягивается до каждого: classNames={{ title: 'text-xl' }}.

  • Атрибуты data-* показывают состояние: data-active, data-pressed, data-vote, data-type. Нажатую реакцию можно оформить без JavaScript.

  • Части и функции рендера. Каждый стандартный макет собран из экспортируемых частей, а renderItem, renderLink и renderImage подставляют вместо них ваши.

Имена классов и слотов, атрибуты data-*, ключи подписей и переменные меняются только в мажорном релизе. На вложенность элементов внутри компонента это обещание не распространяется: заморозить её значило бы заморозить сами компоненты. Если ваш CSS зависит от структуры, собирайте компонент из частей.

Из серверного рендеринга вытекают ещё два небольших решения. Даты абсолютные и считаются в UTC, поэтому сервер и браузер выводят одну и ту же строку и при гидратации не бывает расхождений. А таблица стилей необязательна: без неё все классы и атрибуты на месте, так что команда со своей дизайн-системой может написать весь CSS сама.

Сначала опробуйте на себе, потом публикуйте

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

Это сразу окупилось. При переезде нашлось оглавление, которое застревало на последнем заголовке; причина оказалась в том, что React 19 перезаписывает dangerouslySetInnerHTML при каждом повторном рендере родителя. Нашёлся парсер заголовков, который падал на записи без текста. Ни то ни другое не проявилось в песочнице компонентов, и обе ошибки ушли бы к клиентам.

Коротко

  • Принимайте данные через пропсы. Загрузку оставьте фреймворку.

  • По умолчанию рендерите на сервере, интерактивные части выносите в путь /client.

  • Передавайте клиентским компонентам id и счётчики, а не записи целиком.

  • Делайте фильтры и пагинацию ссылками.

  • Поведение держите в headless-хуках, логику — в обычных функциях.

  • Относитесь к именам классов, слотам, атрибутам data-* и CSS-переменным как к API.


По этим правилам собраны новые компоненты в @kookee/react: блог, журнал изменений со значком «что нового», центр помощи с поиском и голосованием за полезность и дорожная карта только для чтения. Начните со статьи React-компоненты для блога, журнала изменений и центра помощи или посмотрите, как оформить и настроить их.