Blunder dengan ISR Next JS

Thumbnail Image
18/10/2024

Beberapa waktu yang lalu, ketika mengerjakan task untuk develop penambahan fitur menyukai atau likes, alih-alih saya menambahkan fungsionalitas pada fitur secara nggak sadar saya juga menambahkan bug baru pada fitur itu.

Anyway, pengembangan ini memanfaatkan Next JS versi 13 dan menggunakan approachment Incremental Static Regeneration atau biasa disebut ISR. Selain dosa yang saya sebut sebelumnya, ke-khilafan saya yang lain adalah saya lupa "bagaimana cara ISR bekerja pada Next JS".

Tentang Incremental Static Regeneration

Dari dokumentasi resmi Next.JS, ISR dijelaskan sebagai berikut :

Next.js allows you to create or update static pages after you’ve built your site. Incremental Static Regeneration (ISR) enables you to use static-generation on a per-page basis, without needing to rebuild the entire site. With ISR, you can retain the benefits of static while scaling to millions of pages.

Intinya, ISR ini memungkinkan kita untuk menambahkan atau mengubah konten pada halaman statis website tanpa perlu build ulang seluruh halaman seperti halnya SSG ataupun SSR. Atau dalam skenario lain jika ada perubahan data dari service atau API, halaman web tetap mampu untuk menampilkan konten yang terbaru walaupun halaman tersebut melakukan fetching data pada saat di-build.

ISR sendiri menyelesaikan masalah yang dihadapi oleh SSG atau SSR yaitu, konten hanya akan tetap baru jika di-build terakhir kali. Jika data sering berubah, konten statis dapat dengan cepat menjadi usang kecuali jika di-build ulang dan digunakan ulang secara berkala. Dengan ISR, developer mendapatkan benefit dari SSR atau SSG sekaligus menampilkan konten terbaru pada runtime browser.

Jadi, apa yang membuat ISR dapat menyelesaikan masalah tersebut? Jawabannya adalah revalidation.

Mungkin untuk lebih gampang menjelaskan tentang ISR dan revalidation kita perhatikan snippet code berikut :

// app/page.tsx

export const revalidate = 60;
export default async function Home(){
  const time = new Date().toLocaleTimeString()
  const res = await fetch('/api/time');
  const data = await res.json();
  return (
    <div>
      <p>Page generated at: {time}</p>
      <p>Revalidated Page at: {data.time}</p>
    </div>
  )
}
// app/api/time/route.ts

import { NextResponse } from "next/server";

export async function GET(request) {
  return NextResponse.json(
    { 
      time: new Date().toLocaleTimeString()
    }, { status: 200 });
}

Dari code di atas maka browser akan menampilkan halaman berikut :

Hasil generate pertama

Ketika pertama kali halaman di-request browser pertama kali, browser menampilkan halaman yang di-generate pada saat build time. Jika halaman dimuat ulang kurang dari 60 detik, browser akan menampilkan data dari cache (stale), maka dari itu data dari label "Revalidate Page" tetap sama atau tidak berubah.

Hasil refresh kurang dari 60 detik

Sebaliknya, jika halaman dimuat ulang setelah lebih dari 60 detik, Next JS akan melakukan validasi ulang data dari server, kemudian menghapus cache sebelumnya dan memperbaharui cache. Maka data yang akan ditampilkan adalah data terbaru dari server. Proses ini yang disebut sebagai revalidation.

Hasil refresh lebih dari 60 detik

Bagaimana Next JS bisa mengetahui interval waktu cache nya? Seperti yang ditulis dari snippet code di atas sebelumnya saya menambahkan export const reavalidate = 60 yang akan memberi tahu Next JS untuk melakukan revalidate setiap 60 detik.

Tanpa mendefinisikan revalidate pada komponen page, Next JS tidak akan men-trigger perubahan data terbaru dari server dari cache seperti normalnya mekanisme data fetching pada SSG/SSR kecuali di-build ulang.

Skenario ini cukup bermanfaat jika kita mengembangkan website blog ataupun portal berita yang terintegrasi dengan CMS. Kita tetap bisa generate halaman web secara statis untuk memanfaatkan performa dan SEO yang baik dan melakukan perubahan konten melalui CMS tanpa perlu build ulang.

Back to the Trouble

Balik ke masalah awal, bug yang saya buat secara tanpa sadar saat mengembangkan fitur likes kira-kira seperti ini :

failed like

Angka "like" yang telah berubah setelah user klik tombol like tidak persistent ketika halaman dimuat ulang. Kesalahan ini terjadi ketika saya mencoba untuk melakukan passing props langsung darihasil fetching data di parent component ke child component. Kira-kira gambaran code nya seperti ini :

// app/page.tsx
import LikeButton from "../components/LikeButton";

export const revalidate = 60;
export default async function Home(){

  const resLikes = await fetch('/api/like')
  const likes = await resLikes.json();
  
  return (
    <div>
      <LikeButton data={likes} />
    </div>
  )
}
// components/LikeButton.tsx (child component)
"use client"
import { useState, useEffect } from 'react';

export default function LikeButton({data}) { 
  const [likes, setLikes] = useState(0);

  useEffect(() => {
    // Fetch the current like count
    fetch('/api/like')
      .then((res) => res.json())
      .then((data) => setLikes(data.likes))
      .catch((error) => console.error('Error fetching likes:', error));
  }, []);

  const handleLike = () => {
    // Send a POST request to increment the like count
    fetch('/api/like', {
      method: 'POST',
    })
      .then((res) => res.json())
      .then((data) => setLikes(data.likes))
      .catch((error) => console.error('Error liking post:', error));
  };

  return (
    <div>
      <p>Likes: {likes}</p>
      <button onClick={handleLike}>Like</button>
    </div>
  );
}

Saya melakukan passing props "data" di atas dengan alasan supaya angka like yang ditampilkan sesuai dari server. Karena pada awalnya saya mengira ISR mampu melakukan ini secara dinamis. Justru yang terjadi angka like menjadi tidak persistent sebelum Next JS melakukan revalidate. Angka like hanya berubah setelah proses revalidation selesai.

Maka cara bertaubat dari dosa yang dilakukan di atas adalah dengan memisahkan logic pada fungsi like (separation of concerns).

// app/page.tsx
import LikeButton from "../components/LikeButton";

export const revalidate = 60;
export default async function Home(){
  return (
    <div>
      <LikeButton />
    </div>
  )
}
"use client"
import { useState, useEffect } from 'react';

export default function LikeButton() { // hapus props "data"
  const [likes, setLikes] = useState(0);

  useEffect(() => {
    // Fetch data like dari server
    fetch('/api/like')
      .then((res) => res.json())
      .then((data) => setLikes(data.likes)) // update langsung ke state 
      .catch((error) => console.error('Error fetching likes:', error));
  }, []);

  const handleLike = () => {
    // Send a POST request to increment the like count
    fetch('/api/like', {
      method: 'POST',
    })
      .then((res) => res.json())
      .then((data) => setLikes(data.likes))
      .catch((error) => console.error('Error liking post:', error));
  };

  return (
    <div>
      <p>Likes: {likes}</p>
      <button onClick={handleLike}>Like</button>
    </div>
  );
}

Dari perbaikan code di atas :

  • props "data" dihapus dari component LikeButton
  • Memindahkan data fetching dari parent component ke child component.
  • Melakukan update state setelah data fetching berhasil dilakukan.

Dengan pemisahan seperti ini maka angka like akan tetap persistent walaupun parent component mendefinisikan revalidate terhadap data lainnya.

Kesimpulan

ISR memang memungkinkan untuk menampilkan data terbaru dari server tanpa melakukan build ulang seluruh aplikasi. Namun, untuk menampilkan data dinamis pada aplikasi web yang menggunakan ISR perlu memisahkan dengan komponen yang mengimplementasi data fetching di client (CSR).