kodingup.camp

Beranda/Next.js Mastery: Full-Stack, Auth & Deploy

Mutasi Optimistis

Modul 3 dari 115 menit bacaOptimistic Updates

Isi modul

    Kenapa ini penting

    Bayangkan kamu menekan tombol "like" di media sosial, lalu ikon hatinya baru berubah merah satu detik kemudian. Rasanya aplikasi itu lambat atau rusak, padahal server cuma butuh waktu untuk menyimpan. Aplikasi yang terasa cepat biasanya tidak benar-benar lebih cepat; mereka berani menebak hasilnya duluan. Ikon langsung merah saat ditekan, dan baru dibatalkan kalau ternyata server gagal.

    Teknik ini disebut optimistic update (pembaruan optimistis): UI menampilkan hasil yang diharapkan sebelum server mengonfirmasi. Karena sebagian besar mutasi berhasil, pengguna hampir selalu melihat hasil instan. Dalam kasus langka ketika gagal, UI kembali ke kondisi semula dan menampilkan pesan.

    Analogi: pelayan restoran yang baik langsung menulis "es teh 1" di papan pesanan begitu kamu menyebutnya, tanpa menunggu dapur bilang "siap". Kalau ternyata es batu habis, barulah dia kembali, mencoret pesanan, dan minta maaf. Itu jauh lebih nyaman daripada kamu harus berdiri menunggu konfirmasi dapur untuk setiap pesanan.

    Konsep inti

    Hook useOptimistic

    React 19 menyediakan hook useOptimistic khusus untuk pola ini:

    const [optimisticState, setOptimistic] = useOptimistic(state, updateFn)
    

    Kuncinya ada di cara React mengelola "sementara" ini. State optimistis hanya hidup selama sebuah Transition atau Action berjalan. Begitu action selesai, React membuang lapisan optimistis dan kembali memakai state asli. Kalau action berhasil dan server mengirim data terbaru (karena kamu memanggil updateTag atau revalidatePath), state asli sudah berisi data baru, jadi perpindahannya mulus tanpa kedip. Kalau action gagal, state asli tidak berubah, sehingga UI otomatis kembali ke kondisi sebelum klik.

    Wajib di dalam Transition atau Action

    setOptimistic harus dipanggil di dalam Action (function yang dipasang di <form action>) atau di dalam startTransition. Kalau dipanggil di event handler biasa seperti onClick tanpa startTransition, React akan memberi peringatan "An optimistic state update occurred outside a Transition or Action" dan perubahan langsung hilang.

    Pemicu Cara benar
    Submit form <form action={async (formData) => { setOptimistic(...); await serverAction(...) }}>
    Klik tombol onClick={() => startTransition(async () => { setOptimistic(...); await serverAction(...) })}

    Pending UI dan error handling

    Optimistic update sebaiknya tetap jujur. Beri tanda halus bahwa data belum benar-benar tersimpan, misalnya opacity 60% atau teks "mengirim...". Caranya, tambahkan properti seperti pending: true pada item optimistis. Item asli dari server tidak punya properti itu, jadi tanda tersebut hilang sendiri saat data asli datang.

    Untuk error, bungkus pemanggilan Server Action dengan try/catch. Lapisan optimistis tetap akan dibuang otomatis, tetapi pengguna perlu tahu kenapa item yang tadi muncul kini hilang. Tampilkan pesan dengan state biasa (useState) atau notifikasi.

    useOptimistic dibanding hook lain

    Tiga hook ini sering tertukar. useState menyimpan state yang kamu kendalikan penuh, termasuk kapan harus dikembalikan. useActionState menyimpan hasil action setelah selesai, cocok untuk pesan error validasi. useOptimistic menyimpan tebakan sementara selama action berjalan, lalu membuangnya sendiri. Ketiganya bisa dipakai bersama dalam satu form: useOptimistic untuk menampilkan item baru seketika, useActionState untuk pesan dari server, dan useState untuk hal kecil seperti teks error buatanmu sendiri.

    Kapan memakai, kapan jangan

    Pakai optimistic update untuk aksi yang hampir selalu berhasil dan mudah dibatalkan: like, toggle status, menambah komentar, mencentang todo, menghapus item dari daftar. Jangan pakai untuk aksi yang hasilnya tidak bisa ditebak atau berisiko tinggi: pembayaran, transfer saldo, pendaftaran akun. Menampilkan "Pembayaran berhasil" lalu menariknya kembali jauh lebih buruk daripada menunggu dua detik.

    Contoh kode

    Contoh pertama: daftar komentar dengan form. Server Action-nya sederhana. Untuk latihan, kita sengaja membuatnya lambat dan kadang gagal.

    // app/komentar/actions.js
    'use server'
    
    import { revalidatePath } from 'next/cache'
    
    // Penyimpanan memori untuk demo (di proyek nyata: database)
    const comments = [{ id: 1, text: 'Artikel yang bermanfaat!' }]
    
    export async function getComments() {
      return comments
    }
    
    export async function addComment(text) {
      await new Promise((r) => setTimeout(r, 1500)) // simulasi jaringan lambat
      if (text.toLowerCase().includes('gagal')) {
        throw new Error('Server menolak komentar') // simulasi error
      }
      comments.push({ id: comments.length + 1, text })
      revalidatePath('/komentar') // halaman dirender ulang dengan data baru
    }
    
    // app/komentar/CommentList.jsx
    'use client'
    
    import { useOptimistic, useState } from 'react'
    import { addComment } from './actions'
    
    export function CommentList({ comments }) {
      const [error, setError] = useState('')
      const [optimisticComments, addOptimistic] = useOptimistic(
        comments,
        // updateFn: state sekarang + nilai optimistis → tampilan sementara
        (current, newText) => [...current, { id: `temp-${current.length}`, text: newText, pending: true }]
      )
    
      async function formAction(formData) {
        const text = formData.get('text')
        setError('')
        addOptimistic(text) // tampil SEKARANG, sebelum server menjawab
        try {
          await addComment(text)
        } catch {
          setError('Komentar gagal dikirim. Coba lagi.') // item optimistis hilang otomatis
        }
      }
    
      return (
        <div>
          <ul>
            {optimisticComments.map((c) => (
              <li key={c.id} style={{ opacity: c.pending ? 0.5 : 1 }}>
                {c.text} {c.pending && <small>(mengirim...)</small>}
              </li>
            ))}
          </ul>
          {error && <p style={{ color: 'crimson' }}>{error}</p>}
          <form action={formAction}>
            <input name="text" required placeholder="Tulis komentar" />
            <button>Kirim</button>
          </form>
        </div>
      )
    }
    // Ketik "Mantap" → langsung muncul pudar "(mengirim...)" → 1,5 detik kemudian jadi normal.
    // Ketik "ini gagal" → muncul pudar → 1,5 detik kemudian hilang + pesan error merah.
    
    // app/komentar/page.js  (Server Component yang mengambil data asli)
    import { getComments } from './actions'
    import { CommentList } from './CommentList'
    
    export default async function KomentarPage() {
      const comments = await getComments()
      return <CommentList comments={comments} />
    }
    

    Contoh kedua diambil dari dashboard proyek akhir. Satu useOptimistic menangani dua jenis aksi (hapus dan ubah status terbit) lewat objek action dengan properti type, mirip reducer. Karena dipicu tombol, bukan form, setiap aksi dibungkus startTransition.

    // components/PostRows.jsx
    'use client'
    
    import { useOptimistic, startTransition } from 'react'
    import Link from 'next/link'
    import { deletePost, togglePublish } from '@/app/actions/posts'
    
    export function PostRows({ posts }) {
      const [optimisticPosts, applyOptimistic] = useOptimistic(posts, (current, action) => {
        if (action.type === 'delete') return current.filter((p) => p.id !== action.id)
        if (action.type === 'toggle')
          return current.map((p) =>
            p.id === action.id ? { ...p, published: action.published, pending: true } : p
          )
        return current
      })
    
      function onToggle(post) {
        startTransition(async () => {
          applyOptimistic({ type: 'toggle', id: post.id, published: !post.published })
          try {
            await togglePublish(post.id, !post.published)
          } catch {
            alert('Gagal mengubah status, perubahan dibatalkan.')
          }
        })
      }
    
      function onDelete(post) {
        if (!confirm(`Hapus "${post.title}"?`)) return
        startTransition(async () => {
          applyOptimistic({ type: 'delete', id: post.id }) // baris langsung hilang
          try {
            await deletePost(post.id)
          } catch {
            alert('Gagal menghapus, artikel dikembalikan.')
          }
        })
      }
    
      if (optimisticPosts.length === 0) return <p>Belum ada artikel.</p>
    
      return (
        <ul>
          {optimisticPosts.map((post) => (
            <li key={post.id} className={post.pending ? 'opacity-60' : ''}>
              {post.title} {post.published ? '(terbit)' : '(draf)'}
              <Link href={`/admin/${post.id}/edit`}>Edit</Link>
              <button onClick={() => onToggle(post)}>{post.published ? 'Jadikan draf' : 'Terbitkan'}</button>
              <button onClick={() => onDelete(post)}>Hapus</button>
            </li>
          ))}
        </ul>
      )
    }
    

    Di sisi server, togglePublish dan deletePost memanggil updateTag('posts'). Next.js lalu merender ulang halaman dan mengirim props posts yang baru dalam response action yang sama, sehingga lapisan optimistis diganti data asli tanpa request tambahan.

    Kesalahan umum

    1. Memanggil setOptimistic di luar Transition. Di onClick biasa, bungkus dengan startTransition(async () => { ... }). Tanpa itu React memberi peringatan dan perubahan tidak bertahan.
    2. Menyalin props ke useState lalu mengubahnya manual. Dengan pola itu kamu harus mengurus rollback sendiri dan state bisa tidak sinkron dengan server. useOptimistic sudah menangani "kembali ke data asli" secara otomatis.
    3. Lupa me-revalidate di Server Action. Kalau action tidak memanggil updateTag/revalidatePath, props asli tidak pernah berubah. Item optimistis tampil sebentar lalu menghilang walau datanya sudah tersimpan.
    4. Mutasi langsung di updateFn. current.push(x) mengubah array asli dan membuat bug aneh. Selalu kembalikan array/objek baru: [...current, x].
    5. Diam saja saat gagal. Rollback otomatis tanpa pesan membuat pengguna bingung kenapa komentarnya hilang. Selalu try/catch dan tampilkan pesan error.

    Latihan

    1. Mudah, Buat tombol "Suka" dengan hitungan. Pakai useOptimistic agar angka langsung naik 1 saat diklik, sementara Server Action likePost() menunggu 1 detik.
    2. Sedang, Buat daftar todo dengan checkbox "selesai". Centang harus langsung mencoret teks, dan kalau Server Action melempar error, coretan kembali hilang plus muncul pesan.
    3. Menantang, Pada contoh komentar, tambahkan tombol "Coba lagi" yang muncul bila pengiriman gagal, dan mengirim ulang teks yang sama tanpa pengguna mengetik lagi.
    Lihat solusi
    // 1. LikeButton.jsx  (likePost adalah Server Action yang menyimpan +1 lalu revalidatePath)
    'use client'
    import { useOptimistic, startTransition } from 'react'
    import { likePost } from './actions'
    
    export function LikeButton({ postId, likes }) {
      const [optimisticLikes, addLike] = useOptimistic(likes, (current, n) => current + n)
      return (
        <button
          onClick={() =>
            startTransition(async () => {
              addLike(1)
              await likePost(postId)
            })
          }
        >
          ♥ {optimisticLikes}
        </button>
      )
    }
    
    // 2. TodoList.jsx
    'use client'
    import { useOptimistic, useState, startTransition } from 'react'
    import { setDone } from './actions'
    
    export function TodoList({ todos }) {
      const [error, setError] = useState('')
      const [list, toggle] = useOptimistic(todos, (current, { id, done }) =>
        current.map((t) => (t.id === id ? { ...t, done } : t))
      )
      function onChange(todo) {
        setError('')
        startTransition(async () => {
          toggle({ id: todo.id, done: !todo.done })
          try {
            await setDone(todo.id, !todo.done)
          } catch {
            setError(`Gagal memperbarui "${todo.text}"`)
          }
        })
      }
      return (
        <>
          {list.map((t) => (
            <label key={t.id} style={{ display: 'block', textDecoration: t.done ? 'line-through' : 'none' }}>
              <input type="checkbox" checked={t.done} onChange={() => onChange(t)} /> {t.text}
            </label>
          ))}
          {error && <p>{error}</p>}
        </>
      )
    }
    
    // 3. Simpan teks yang gagal, lalu kirim ulang dalam Transition
    const [failedText, setFailedText] = useState(null)
    
    async function send(text) {
      setFailedText(null)
      addOptimistic(text)
      try {
        await addComment(text)
      } catch {
        setFailedText(text)
      }
    }
    
    // di form:   <form action={(fd) => send(fd.get('text'))}>
    // di bawahnya:
    {failedText && (
      <button onClick={() => startTransition(() => send(failedText))}>
        Coba lagi kirim "{failedText}"
      </button>
    )}
    

    Ringkasan

    Video pendamping

    Kalau penjelasan di atas belum masuk, tonton ini. Video dari kanal YouTube pihak lain.

    React 19's useOptimistic: EVERYTHING you NEED to knowJack Herrington · Bahasa InggrisBuka di YouTube

    Kuis pemahaman

    Pilih satu jawaban per soal. Penjelasan muncul setelah kamu menjawab.