Что происходит после мутации: revalidatePath и revalidateTag

мутации≈ 11 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Действие отработало, база записала новую строку, ошибок нет — а список на экране прежний. Это не сбой и не задержка: пока действие не сказало, что именно устарело, Next даже не собирает страницу заново. Сказать это можно двумя способами — по адресу через revalidatePath или по метке через revalidateTag.

Разделы темы:·

В части II, в статье про рендер из состояния, у вас были массив и функция render(). Добавили задачу — позвали render. Забыли позвать — экран остался прежним, и это никого не удивляло: перерисовка сама не случается.

Серверное действие забрало preventDefault, сборку FormData и fetch. А эту строчку — не забрало. Только теперь «позвать render» означает «попросить сервер собрать страницу заново», и решение, делать это или нет, Next принимает сам, по одному признаку: пометило ли действие хоть что-нибудь устаревшим.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Модель огрубляет детали, но не суть. Ответ на отправку формы с серверным действием — это не JSON: вместе с результатом функции туда кладётся заново собранная разметка текущего маршрута. Кладётся, только если в ходе действия что-то пометили устаревшим. Иначе Next пропускает сборку страницы целиком: у него нет причин считать, что показанное устарело.

Запись куки внутри действия тоже считается пометкой: после cookies().set() страница пересобирается без единого revalidate — поэтому вход в аккаунт обновляет экран сам.

Отсюда правило, к которому сводится вся статья: после мутации инвалидацию зовёте вы. Двух функций хватает на все случаи, и различаются они тем, что именно помечают — адрес или данные.

Первый способ — назвать маршрут: revalidatePath("/tasks"). Функция приезжает из next/cache, работает только на сервере и возвращает undefined — она ничего не загружает и ничего не ждёт. Всё, что она делает, — кладёт адрес в список устаревшего; список применяется потом, после того как действие вернётся.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Страница задачи осталась старой, хотя показывает те же данные. Это не изъян модели, а поведение настоящей функции: без второго аргумента помечается ровно один адрес. Чтобы накрыть вложенные, нужен revalidatePath("/tasks", "layout") — тогда устареют все адреса, которые делят с /tasks раскладку.

ВызовЧто помечается устаревшим
revalidatePath("/tasks")ровно один адрес — /tasks
revalidatePath("/tasks", "layout")/tasks и всё под ним: /tasks/42, /tasks/42/edit
revalidatePath("/tasks/[id]", "page")все страницы динамического сегмента разом
revalidatePath("/", "layout")весь сайт — самый тяжёлый молоток

Аргумент — маршрут, а не URL: без домена, без строки запроса. Динамический адрес можно передать в двух видах — конкретным /tasks/42 или шаблоном /tasks/[id], но во втором случае второй аргумент обязателен. Забыли — Next отделается строкой в логе и не сделает ничего: Warning: a dynamic page path "/tasks/[id]" was passed to "revalidatePath", but the "type" parameter is missing.

А вот вариантов у самого type ровно два, и третий компилятор не пропустит.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

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

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Пример молчит, и так задумано: серверной стороны у песочницы нет, а «use server» для компилятора — просто строка. Проверяется здесь другое: что код с такими типами вообще складывается.

Вызов не ходит в базу, не собирает разметку и не возвращает промис — await revalidatePath(...) написать можно, но ждать там нечего. Функция только копит пометки в служебном хранилище текущего запроса. Применяются они разом, когда действие уже вернулось: Next выбрасывает помеченное из кеша и только после этого собирает страницу для ответа.

Отсюда два следствия. Первое: звать revalidatePath несколько раз подряд дёшево, повторы схлопнутся. Второе: внутри самого действия обновлённого кеша ещё нет — оно уже вернулось, когда пометки применяются. Ради случая «прочитать то, что сам только что записал» в Next 16 и появился updateTag, который выбрасывает запись немедленно.

Список задач редко живёт на одном адресе. Он же в шапке счётчиком, он же на главной, он же на странице задачи как «соседние». Перечислять все адреса в каждом действии — работа, которая ломается ровно в тот день, когда появится четвёртый адрес, а про действие никто не вспомнит.

Метка переворачивает связь. Помечается не страница, а сами данные: cacheTag("tasks") ставится там, где их читают, а revalidateTag("tasks", "max") — там, где меняют. Кто из страниц эти данные показывает, знает кеш, и знает точнее вас.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Обратите внимание на последнюю строку: записей стало две, а не три. Страницу /tasks из кеша выбросили, но никто её ещё не запрашивал — и не собирал. Пометка не перерисовывает; она выкидывает устаревшее, а пересчёт случится при следующем визите. Страница /about с другой меткой не шелохнулась.

В настоящем проекте это выглядит так. Метку вешают внутри кешируемой функции, инвалидируют — внутри действия, и ни одного адреса в коде нет.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Второй аргумент — новинка Next 16, и она стоит отдельного абзаца. Раньше revalidateTag принимал одно имя; теперь просят добавить профиль жизни записи ("max", "hours", объект { expire }) — то есть сказать, насколько просроченной запись считать. Старый вызов с одним аргументом работает, но встречает предупреждение об устаревании, а компилятор встречает его раньше.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Диагностика на восьмой строке — про недостающий аргумент, и это тот случай, когда компилятор говорит ровно то же, что скажет Next при выполнении. А ниже видно, чем кончается вызов, который компилятор осудил: код всё равно выполнился, и profile внутри оказался undefined.

ФункцияЧто делаетОткуда можно звать
revalidateTag(tag, profile)помечает записи с меткой просроченными по правилам профилядействие, обработчик маршрута
updateTag(tag)выбрасывает записи немедленно — тот же ответ уже увидит новые данныетолько серверное действие
refresh()обновляет на клиенте динамические данные, кеша не касаетсятолько серверное действие

Метку понимает и fetch: fetch(url, { next: { tags: ["tasks"] } }) кладёт ответ в кеш с той же меткой, и revalidateTag выбросит его наравне с «use cache»-функциями.

Обе функции выглядят безобидно — одна строка в конце действия. Ломаются они не в самой строке, а в том, что вокруг неё.

Первое место — забытый await перед записью. Действие вернётся раньше, чем база успеет записать; Next честно выбросит страницу из кеша и честно соберёт её заново — из тех данных, которые есть сейчас, то есть из старых. Хуже того, свежесобранная старая страница ляжет в кеш, и следующего сброса не будет.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Через двести миллисекунд база знает про две задачи, а кеш — про одну, и сам он больше не обновится: пометку уже потратили. Компилятор такое не ловит — тип у insertTask(title) честный Promise<void>, просто его никто не подождал. Ловит линтер: правило no-floating-promises из статьи «typescript-eslint: правила, которых нет у компилятора» существует ровно для таких строк.

Второе место — отрисовка. Позвать инвалидацию из тела серверного компонента заманчиво («данные протухли, обновлю прямо тут»), но Next обрывает такой запрос с ошибкой:

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

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

Третье место — код после redirect(). Редирект в Next устроен на брошенном исключении, и всё, что стоит ниже, не выполнится. Пометка должна стоять до него.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Тип redirect — never: компилятор знает, что до следующей строки дело не дойдёт, и подсветит недостижимый код сам. Разбор never был в части III.

Бывает и наоборот: инвалидацию написали правильно, сервер отдаёт свежие данные, а открытая вкладка упорно показывает прежнее. Значит, дело в другом кеше — том, что живёт в браузере. Роутер хранит присланную разметку посещённых и предзагруженных маршрутов, чтобы переход назад был мгновенным (об этом была статья про Link и префетч).

Ответ серверного действия обновляет этот кеш сам — там же приезжает пересобранная разметка. Но если данные изменились мимо действия, обновлять его нечем.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Между второй и третьей строкой вывода экран врал. Чинит это router.refresh() из useRouter(): он просит у сервера разметку текущего маршрута заново, не трогая ни состояние компонентов, ни позицию прокрутки. Кнопка в примере делает ровно это — можете нажать её сами и посмотреть, как кадр догоняет базу.

Сложить всё вместе проще таблицей. Кешей три, и у каждого свой рычаг.

Что лежитГде живётЧем сбрасывается
результат кешируемой функциисерверrevalidateTag, updateTag
собранная страница маршрутасерверrevalidatePath
присланная разметка маршрутавкладка браузераответ действия, router.refresh(), refresh()

Отсюда правило выбора: меняли данные — метка или адрес; данные поменялись без вашего действия (вебхук, вторая вкладка, чужая правка) — router.refresh() на клиенте.

Сроки жизни этих кешей, профили cacheLife и то, какие страницы вообще попадают в кеш, — тема следующей главы, про кеш и прод; сроки и профили разбирает её статья «Cache Components и директива 'use cache'». Здесь достаточно одного: после мутации сброс кеша — ваша строчка кода, и написать её надо в правильном месте.

  • После действия Next пересобирает маршрут только тогда, когда в ходе действия что-то пометили устаревшим; иначе сборка страницы пропускается и обновлять экран нечем.
  • revalidatePath(path, type?) помечает адрес: без второго аргумента — ровно один маршрут, с "layout" — все вложенные; для шаблона /tasks/[id] второй аргумент обязателен.
  • revalidateTag помечает данные, а не страницы: метку ставят при чтении через cacheTag, и одно имя накрывает все адреса, которые эти данные показывают.
  • В Next 16 у revalidateTag появился второй аргумент — профиль; для немедленного сброса внутри действия есть updateTag, для обновления клиентских данных — refresh().
  • Пометка ставится после await записи и до redirect; из отрисовки серверного компонента звать её нельзя — Next обрывает запрос ошибкой.
  • Кешей три: данные и собранные страницы на сервере плюс разметка маршрутов во вкладке; последнюю обновляет ответ действия или router.refresh().

1/4Серверное действие записало задачу в базу и вернулось. Ни revalidatePath, ни revalidateTag в нём нет. Что приедет в ответе на отправку формы?

2/4Действие изменило задачу и вызвало revalidatePath("/tasks"). У посетителя открыт адрес /tasks/42 — страница этой самой задачи. Обновится ли она?

3/4В проекте на Next 16 внутри действия написали revalidateTag("tasks") — с одним аргументом, как привыкли. Что будет?

4/4Данные в базе поменял вебхук от платёжной системы — никакого серверного действия из этой вкладки не было. Что обновит открытую страницу?

Действие без обновления

Форма добавляет задачу, действие честно пишет в базу, ошибок нет — а список на экране не меняется до перезагрузки. Найдите недостающую строку и допишите её.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Не хватает пометки: без неё Next не пересобирает маршрут после действия и в ответе нет новой разметки.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Строка стоит после await, а не до: пометка, поставленная раньше записи, приведёт к тому, что страница пересоберётся из ещё не изменившихся данных — и старый результат снова ляжет в кеш.

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

Три адреса, одна метка

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

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

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

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь новая страница со списком получает свежие данные бесплатно: она зовёт ту же getTasks, а значит попадает под ту же метку. Обратная сторона — метку надо придумывать осмысленно: "tasks" сбрасывает весь список сразу, и если страниц с задачами станет много, метку дробят по смыслу, например cacheTag("task-" + id) для одной задачи.

Пометка не помогла

Действие содержит и запись, и revalidatePath, но список обновляется через раз: иногда сразу, иногда после второй отправки. Объясните, почему, и почините.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ошибок здесь две, и они усиливают друг друга. Первая — порядок: пометка стоит до записи. Вторая — забытый await: insertTask возвращает промис, действие его не ждёт и завершается раньше, чем база что-то записала.

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

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Компилятор такое не ловит: тип выражения insertTask(title) честный Promise<void>, просто результат никому не нужен. Ловит линтер — правилом no-floating-promises.