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)
state: data "asli", biasanya props dari Server Component (misalnya daftar komentar dari database).updateFn(currentState, optimisticValue): function murni yang menghitung tampilan sementara. Misalnya menambahkan komentar baru ke array.optimisticState: yang kamu render. Selama tidak ada aksi berjalan, nilainya sama denganstate.setOptimistic(value): memicu pembaruan sementara.
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
- Memanggil
setOptimisticdi luar Transition. DionClickbiasa, bungkus denganstartTransition(async () => { ... }). Tanpa itu React memberi peringatan dan perubahan tidak bertahan. - Menyalin props ke
useStatelalu mengubahnya manual. Dengan pola itu kamu harus mengurus rollback sendiri dan state bisa tidak sinkron dengan server.useOptimisticsudah menangani "kembali ke data asli" secara otomatis. - 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. - Mutasi langsung di
updateFn.current.push(x)mengubah array asli dan membuat bug aneh. Selalu kembalikan array/objek baru:[...current, x]. - Diam saja saat gagal. Rollback otomatis tanpa pesan membuat pengguna bingung kenapa komentarnya hilang. Selalu
try/catchdan tampilkan pesan error.
Latihan
- Mudah, Buat tombol "Suka" dengan hitungan. Pakai
useOptimisticagar angka langsung naik 1 saat diklik, sementara Server ActionlikePost()menunggu 1 detik. - Sedang, Buat daftar todo dengan checkbox "selesai". Centang harus langsung mencoret teks, dan kalau Server Action melempar error, coretan kembali hilang plus muncul pesan.
- 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
- Optimistic update menampilkan hasil yang diharapkan sebelum server menjawab, supaya UI terasa instan.
useOptimistic(state, updateFn)membuat lapisan sementara di atas data asli; lapisan itu dibuang saat action selesai.setOptimisticwajib dipanggil di dalam form Action ataustartTransition.- Server Action harus me-revalidate (
updateTag/revalidatePath) agar data asli ikut diperbarui dalam roundtrip yang sama. - Saat gagal, UI kembali otomatis; tugasmu menampilkan pesan error yang jelas.
- Hindari optimistic update untuk aksi berisiko tinggi seperti pembayaran.