Wizards of code logoWizards of code logo

'use cache' w next.js

use-cache

Wstęp i kawałek historii

W programowaniu mamy tylko dwie trudne rzeczy: nazwanie rzeczy (zmiennych i funkcji) i cache invalidation.

Dlaczego cache jest ważny? Bo zawsze chcemy, żeby nasza aplikacja działała szybko. Czasami musimy pobierać dane z zewnętrznych źródeł, które mogą wolno odpowiadać.

W poprzednich wersjach nexta, twórcy próbowali zaimplementować cache w sposób, który byłby łatwy w użyciu, ale nie zawsze działał jak trzeba. Wtedy wprowadzili bardzo agresywne cacheowanie, które cachowało zawsze wszystko i nie można było tego zmienić, bez konkretnego oznaczenia funkcji. Problem pojawiał się gdy korzystaliśmy z biblioteki, która korzystała z fetcha "pod spodem", a fetch został nadpisany przez twórców nexta. Wtedy nie dało się obejść tego mechanizmu i wyłączyć cache. To powodowało wiele błędów i wyświetlało nieaktualne dane w aplikacji.

W next 15 twórcy zmienili podejście i wprowadzili nowy experymentalny feature "use cache". Wszystkie poprzednie mechanizmy nadal będą wspierane ze względu na kompatybilność wsteczną. Dla przypomnienia mechanizmów tych było sporo: unstable_cache(), export const dynamic, runtime, fetchCache, dynamicParams, revalidate. Moim zdaniem ich mnogość to była pułapka.

Twórcy next.js twierdzą że wymyślili coś lepszego (jak zwykle ;) - dynamicIO. Wprowadzili nowy tryb oparty o Suspense i oznaczenie "use cache".

Jak to włączyć?

Wystarczy, że w pliku next.config.ts

import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  experimental: {
    dynamicIO: true,
  }
};

export default nextConfig;

dopiszemy w experimental flagę dynamicIO: true.

Od teraz podczas pobierania danych, używania cookies, headers, current time albo wartości losowych trzeba będzie wybrać:

  • czy chcemy cachować dane na serwerze,
  • czy chcemy cachować dane po stronie klienta,
  • czy chcemy żeby dane były pobierane przy każdym request. Nie chodzi tu tylko o fetcha ale o każdy kod asynchroniczny np. Node API, bazę danych, timer.

First look

Stwórzmy kawałek kodu, który pobierze dane z API i wyświetli je na stronie. W tym przykładzie użyjemy funkcji asynchronicznej, która w przyszłości może pobrać dane z bazy danych.

import { faker } from '@faker-js/faker';

export async function getProduct() {
  return faker.commerce.productDescription();
}

export async function getPrice() {
  return faker.commerce.price();
}

Użyjmy tego w komponencie:

import { getProduct, getPrice } from './api/products';

export default async function ProductPage() {
  const product = await getProduct();
  const price = await getPrice();

  return (
    <div>
      <h1>{product}</h1>
      <h2>{price}</h2>
    </div>
  );
}

Teraz w przeglądarce dostaniemy błąd informujący o tym, że musimy wybrać czy używamy 'use cache' czy Suspense. Next.js wymusza na nas włączenie cache i wybór odpowiedniej strategii.

Podejście 1: Wybieramy, że strona ma być DYNAMIC

Jeśli zależy Ci na dociąganiu danych na żywo. To musisz obsłużyć loadery, więc możesz owrapować komponent asynchroniczny w Suspense, a on będzie strumieniowany do przeglądarki, czyli dotrze do niej wtedy gdy będzie gotowy. Reszta struktury pojawi się w przeglądarce wcześniej.
W next.js po stworzeniu pliku loading.tsx owrapowanie w Suspense naszego Page dzieje się "pod spodem". To pomoże Ci w dostarczeniu głównej struktury natychmiast do przeglądarki, a reszta dociągnie się później. Wypróbujmy to podejście tworząc plik loading.tsx:

export default function Loading() {
  return <div>Loading...</div>;
}

Tak samo możemy osiągnąć to bez pliku loading.tsx dodając Suspense w komponencie rodzica np. w layout.tsx

import { Suspense } from 'react';

export default function ProductLayout({ children }) {
  return <Suspense fallback={<div>Loading...</div>}>{children}</Suspense>;
}

Podejście z plikiem loading sprawdzi się bardzo dobrze dla całych page, a podejście z Suspense w mniejszych komponentach asynchroncznych.

NAJWAŻNIEJSZE: w tym momencie nic nie będzie cachowane, nie będzie żadnego ukrytego/domyślnego cache.

Podejście 2: Wybieramy, że strona ma być STATYCZNA

Jeśli budujesz coś statycznego i nie chcesz używać dynamicznych funkcji możesz użyć nowej dyrektywy "use cache" na górze pliku strony. Dzięki niej informujesz, że dany segment ma być w cache i strona zostanie wyrenderowana statycznie. Tutaj nie używam Suspense, każdy fetch na tej stronie zostanie w cache.

'use cache';
import { getProduct, getPrice } from './api/products';

export default async function ProductPage() {
  const product = await getProduct();
  const price = await getPrice();

  return (
    <div>
      <h1>{product}</h1>
      <h2>{price}</h2>
    </div>
  );
}

NAJWAŻNIEJSZE: w tym momencie dane są w cache, czyli pobiorą się tylko przy pierwszym renderowaniu strony (np. podczas builda). Pozostaną w cache przez domyślnie określony czas (o tym w dalszej części).

Podejście 3: Wybieramy, że strona ma być HYBRYDOWA

Tutaj pojawia się problem: co z danymi, które się zmieniają? Jaki jest domyślny czas cachowania? Jak zrobić cache invalidation?

Obecnie w next.js domyślny czas cachowania to 15 minut. Możemy to zmienić, używając dyrektywy "cacheLife" na górze funkcji/komponentu.

'use cache';
import { getProduct, getPrice } from './api/products';
import { unstable_cacheLife as cacheLife } from 'next/cache';

export default async function ProductPage() {
  cacheLife('hours');
  const product = await getProduct();
  const price = await getPrice();

  return (
    <div>
      <h1>{product}</h1>
      <h2>{price}</h2>
    </div>
  );
}

cacheLife akceptuje: seconds, minutes, hours, days, weeks, max, albo dokładną wartość w sekundach. Można też dodać swoje tryby w next.config.ts.

Co z danymi, które mogą się zmieniać po akcji użytkownika? Możemy użyć cacheTag, który pozwala na ręczne dodanie tagu do cache. Tag ten jest dowolną nazwą - stringiem.

'use cache';
import { getProduct, getPrice } from './api/products';
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache';

export default async function ProductPage() {
  cacheLife('hours');
  cacheTag('my-product-id');
  const product = await getProduct();
  const price = await getPrice();

  return (
    <div>
      <h1>{product}</h1>
      <h2>{price}</h2>
    </div>
  );
}

Możemy użyć "use cache" w dowolnej funkcji asynchronicznej i to będzie oznaczać, że cachujemy tylko tę funkcję.
Podczas używania takiego hybrydowego podejścia, lepiej dodać cachowanie do API calls. Możemy dodawać dyrektywę "use cache" do jakiejkolwiek funkcji asynchronicznej, tak samo jak dyrektywy "use server". Możesz myśleć o tym tak samo jak o Server Action ale odwołujesz się do cache. Nie musisz nadawać kluczy cache manualnie, zrobi się to automatycznie. Moim zdaniem jednak lepiej zawsze robić to ręcznie, użyjemy do tego cacheTag

import { faker } from '@faker-js/faker';
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache'

// getProductDesc ma cache na godziny i cacheTag per id
export async function getProductDesc(id:string) {
  "use cache"
  cacheLife("hours")
  cacheTag("my-product-"+id)
  return faker.commerce.productDescription();
}
// getPrice nie ma cache, pojawi się błąd że trzeba użyć Suspense
export async function getPrice() {
  return faker.commerce.price();
}

i później wywołać revalidateTag z Server Action. Tak jak wcześniej, w tym miejscu nie ma zmian. Dla przypomnienia robimy to w ten sposób:

'use server';

import { revalidateTag } from 'next/cache';

export default async function submit() {
  await addProduct();
  revalidateTag('my-data');
}

Podejście z cacheTag i revalidateTag jest bardzo podobne do obsługi cache za pomocą tanstack query, w tamtym przypadku używaliśmy queryClient.invalidateQueries('nazwa-klucza'), a do oznaczania cache używaliśmy kluczy w hooku useQuery.

Źródła:

Blogpost na blogu next'ahttps://nextjs.org/blog/our-journey-with-caching