В распределенных веб-приложениях управление состоянием традиционно балансирует между двумя моделями согласованности:
В Next.js 13–15 основной функцией сброса данных по тегам являлась revalidateTag(). Она реализована по принципу Stale-While-Revalidate. При вызове в теле Server Action функция revalidateTag() не пересчитывала данные синхронно, а лишь переводила тег в состояние stale. В результате пользователь после отправки формы мог столкнуться с неприятными спецэффектами: корзина на доли секунды возвращала старое количество товаров, профиль мигал старым именем, а инженерам приходилось костылить клиентские таймеры или вызывать router.refresh().
В Next.js 16 для Server Actions представлена специализированная функция updateTag(), которая гарантирует строгую семантику Read-Your-Own-Writes.
Различие между updateTag() и revalidateTag() заключается в моменте и синхронности пересчета кэшированных данных:
revalidateTag(tag) — АСИНХРОННЫЙ ФОНОВЫЙ СБРОС (Eventual Consistency):
Клиент (Action) ──► Сервер ──► DB Update ──► revalidateTag('cart') ──► Ответ OK
│
Пометка тега: STALE
(Данные НЕ пересчитаны!)
│
Следующий запрос ──────────────────────────────────┼──► Отдает STALE ──► В фоне: Recompute
updateTag(tag) — СИНХРОННЫЙ ПЕРЕСЧЕТ В ПОТОКЕ ACTION (Read-Your-Own-Writes):
Клиент (Action) ──► Сервер ──► DB Update ──► updateTag('cart')
│
СИНХРОННАЯ ИНВАЛИДАЦИЯ
+ НЕМЕДЛЕННЫЙ РЕРЕНДЕР КОМПОНЕНТА
│
Ответ клиенту: СВЕЖИЙ React Flight Payload ◄───┘
(Клиент видит актуальные данные мгновенно!)revalidateTag(tag)#updateTag(tag)#router.refresh().Пользователь нажимает кнопку в интерфейсе. Браузер отправляет POST-запрос с аргументами мутации на сервер Next.js.
Сервер валидирует входные данные (Zod) и фиксирует изменения в базе данных внутри транзакции (ACID).
Вызов updateTag('cart:${id}') сбрасывает кэш и синхронно перезапускает рендеринг затронутых RSC-компонентов.
Сервер возвращает единый HTTP-ответ, содержащий как статус мутации, так и актуальное обновленное поддерево интерфейса.
React на клиенте бесшовно подменяет изменившиеся узлы DOM без Layout Shift и без дополнительных сетевых запросов.
Почему при обновлении количества товаров в корзине пользователя через Server Action следует использовать updateTag(), а не revalidateTag()?
Пользователь кликает «В корзину». Server Action записывает позицию в базу данных и вызывает updateTag('cart:${cartId}'). Заголовок сайта со счетчиком товаров и модальное окно корзины мгновенно получают свежие данные в том же ответе без перезагрузки страницы и без артефактов CLS.
Клиент переходит с тарифа Free на Enterprise. Server Action списывает оплату через платёжный шлюз и вызывает updateTag('tenant:${tenantId}:billing'). Компоненты доступа к закрытым фичам дашборда мгновенно разблокируются без необходимости выхода из системы или ручного обновления страницы.
Пользователь обновляет имя и аватар. Вызов updateTag('user:${userId}') синхронно перерендеривает шапку, сайдбар и карточку настроек, гарантируя отсутствие эффекта «старого имени» при навигации по сайту.
revalidateTag)#Внешний сервис Strapi или Sanity присылает webhook о публикации новой статьи блога. В Route Handler вызывается revalidateTag('posts'). Здесь идеально подходит именно revalidateTag(), так как фоновый сброс не блокирует ответ вебхуку внешнего сервера (время ответа менее 20 мс).
updateTag#'use server';
import { unstable_updateTag as updateTag } from 'next/cache';
import { db } from '@/lib/db';
import { getSessionCartId } from '@/lib/session';
export interface AddToCartResult {
success: boolean;
itemCount?: number;
error?: string;
}
export async function addToCartAction(productId: string, quantity: number): Promise<AddToCartResult> {
const cartId = await getSessionCartId();
if (!cartId) {
return { success: false, error: 'Сессия не найдена' };
}
try {
// 1. Атомарная мутация в базе данных
const updatedCart = await db.$transaction(async (tx) => {
await tx.cartItem.upsert({
where: { cartId_productId: { cartId, productId } },
update: { quantity: { increment: quantity } },
create: { cartId, productId, quantity },
});
return tx.cart.findUnique({
where: { id: cartId },
include: { items: true },
});
});
// 2. Синхронная инвалидация и перерендеринг поддерева RSC
// Все кэшированные компоненты с тегом `cart:${cartId}`
// будут пересчитаны ПРЯМО СЕЙЧАС до отправки HTTP-ответа
updateTag(`cart:${cartId}`);
const totalCount = updatedCart?.items.reduce((acc, i) => acc + i.quantity, 0) ?? 0;
return {
success: true,
itemCount: totalCount,
};
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Ошибка обновления корзины';
return { success: false, error: message };
}
}import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache';
import { db } from '@/lib/db';
interface Props {
cartId: string;
}
export async function CartHeaderWidget({ cartId }: Props) {
'use cache';
cacheLife('days');
// Привязываем компонент к конкретному тегу корзины сессии
cacheTag(`cart:${cartId}`);
const cart = await db.cart.findUnique({
where: { id: cartId },
include: { items: true },
});
const count = cart?.items.reduce((sum, item) => sum + item.quantity, 0) ?? 0;
return (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg border border-border bg-bg-surface">
<span className="text-sm font-medium text-text-primary">Корзина:</span>
<span className="font-mono font-bold text-accent-cyan px-2 py-0.5 rounded bg-accent-cyan/10">
{count}
</span>
</div>
);
}revalidateTag в Webhook API#import { NextRequest, NextResponse } from 'next/server';
import { revalidateTag } from 'next/cache';
export async function POST(req: NextRequest) {
const authHeader = req.headers.get('x-webhook-secret');
if (authHeader !== process.env.CMS_WEBHOOK_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const payload = await req.json();
const { model, id } = payload;
// revalidateTag идеален для вебхуков: ответ отдается мгновенно (5 мс),
// а Next.js в фоне переведет теги в состояние stale
if (model === 'post') {
revalidateTag('posts');
revalidateTag(`post:${id}`);
} else if (model === 'author') {
revalidateTag(`author:${id}`);
}
return NextResponse.json({ revalidated: true, timestamp: Date.now() });
}Server Action выполняет запись в реляционную базу данных и ожидает успешного подтверждения транзакции (Commit).
Вызов updateTag должен происходить строго после фиксации транзакции.
Next.js аннулирует записи в cacheHandler по переданному тегу и переводит их в состояние принудительного пересчета.
Кэш сбрасывается атомарно для текущей транзакции запроса.
Сервер рендерит затронутые RSC-компоненты заново со свежими данными из БД и упаковывает их в единый Flight Payload.
Клиенту не требуется слать отдельный GET-запрос.
React на клиенте получает ответ экшена и применяет изменения к дереву компонентов с сохранением фокуса и скролла.
Метрика CLS равна 0.
Разработчик вызвал updateTag('profile') внутри Server Action, а затем в клиентском компоненте формы сразу после вызова экшена выполнил router.refresh(). К чему это приведет?
Если вызвать updateTag() до того, как транзакция в базе данных зафиксирована (COMMIT), повторный рендеринг серверного компонента прочитает еще не зафиксированные старые данные из параллельного соединения к БД. В результате закэшируется устаревшее состояние.
Решение: Вызывайте updateTag() строго после успешного завершения блока await db.$transaction(...).
Вызов updateTag('products') при изменении количества товара в корзине одного покупателя приведет к глобальному сбросу и синхронному перерендерингу каталога товаров для всех сотен тысяч посетителей сайта.
Решение: Разделяйте теги на общие сущности каталога (products) и изолированные пользовательские теги (cart:${cartId}, user:${userId}).
updateTag() спроектирован для архитектуры Server Actions, где сервер возвращает мутационный Flight Payload. Вызов updateTag() внутри обычного API Route Handler (app/api/.../route.ts) приведет к ошибке рантайма или будет проигнорирован.
Решение: Внутри Route Handlers и Webhook API используйте стандартный revalidateTag().