قسمت دوازدهم - loading، error و not-found در Next.js
بعد از اینکه داده واقعی را وارد صفحهها میشود، صفحهها همیشه در حالت ایدهآل نیستند.گاهی داده هنوز نرسیده است. گاهی کوئری با خطا روبهرو میشود. گاهی هم اصلاً محتوایی برای یک slug وجود ندارد.
loading، error و not-found در Next.js
بعد از اینکه داده واقعی را وارد صفحهها میکنیم، یک مسئله مهم خیلی زود خودش را نشان میدهد:
صفحهها همیشه در حالت ایدهآل نیستند.
گاهی داده هنوز نرسیده است.
گاهی کوئری با خطا روبهرو میشود.
گاهی هم اصلاً محتوایی برای یک slug وجود ندارد.
در Next.js با App Router، این حالتها با چند convention مشخص مدیریت میشوند:
loading.tsxerror.tsxnot-found.tsx
این فایلها فقط جزئیات اضافه نیستند. در عمل، بخشی از معماری واقعی صفحه هستند.
اگر آنها را از ابتدا درست طراحی کنی، UX پروژه بسیار طبیعیتر، حرفهایتر و قابل نگهداریتر میشود.
چرا این stateها مهماند؟
وقتی پروژه هنوز با mock data کار میکند، همهچیز بیش از حد تمیز بهنظر میرسد.
آرایه آماده است، خطایی وجود ندارد و همیشه چیزی برای نمایش هست.
اما در پروژه واقعی این فرضها درست نیستند.
مثلاً در یک پرتفولیو یا CMS:
- لیست پروژهها ممکن است هنوز در حال دریافت باشد
- کوئری دیتابیس ممکن است موقتاً fail شود
- کاربر ممکن است به
slugای برود که وجود ندارد - یک بخش از صفحه ممکن است آماده باشد و بخش دیگر هنوز در حال load شدن باشد
اگر این وضعیتها را مدیریت نکنی، نتیجه معمولاً یکی از اینها میشود:
- صفحهای که ناگهان خالی میماند
- خطای خام و نامناسب برای کاربر
- تجربهای نامشخص هنگام navigation
- نبود مسیر روشن برای retry یا بازگشت
برای همین، loading، error و not-found باید بخشی از طراحی واقعی routeها باشند، نه چیزی که در پایان کار به پروژه اضافه میشود.
ساخت loading.tsx
در App Router، اگر داخل یک route segment فایلی به نام loading.tsx بسازیم، Next.js آن را بهعنوان fallback همان segment استفاده میکند.
مثلاً:
// app/projects/loading.tsx
export default function Loading() {
return <p>Loading projects...</p>
}
بهمحض اینکه کاربر وارد این route شود و محتوای آن هنوز آماده نباشد، این UI نمایش داده میشود و بعد از کامل شدن رندر، محتوای اصلی جای آن را میگیرد.
نکته مهم این است که loading.tsx در عمل با Suspense کار میکند و برای همان segment یک loading state خودکار میسازد.
این فایل دقیقاً کجا اثر میگذارد؟
در همان segment، loading.tsx داخل layout.tsx قرار میگیرد و page.tsx و فرزندانش را پوشش میدهد.
اما یک نکته مهم در داک رسمی این است که اگر خود layout.tsx به داده uncached یا runtime access وابسته باشد، loading.tsx لزوماً fallback آن را نمایش نمیدهد.
در عمل، یعنی اگر میخواهی navigation سریعتر و fallback قابلاعتمادتر باشد:
- دریافت داده سنگین را تا جای ممکن به
page.tsxمنتقل کنیم - یا بخشهای کند را با
Suspenseجداگانه بپوشانیم
loading.tsx را کجا استفاده کنیم؟
برای routeهایی مثل اینها:
app/projects/loading.tsxapp/blog/loading.tsxapp/dashboard/loading.tsx
این فایل معمولاً مناسب است وقتی میخواهیم برای کل route یک fallback اولیه داشته باشیم.
skeleton loading
از نظر فنی، loading.tsx میتواند فقط یک متن ساده برگرداند.
اما در UI واقعی، معمولاً بهتر است بهجای Loading... از skeleton استفاده کنیم.
مثال ساده:
// app/projects/loading.tsx
export default function Loading() {
return (
<section className="grid gap-6 md:grid-cols-2">
{Array.from({ length: 4 }).map((_, index) => (
<div
key={index}
className="h-48 animate-pulse rounded-md border border-white/10 bg-white/5"
/>
))}
</section>
)
}
چرا این مدل بهتر است؟
- کاربر سریعتر میفهمد چه چیزی در حال load شدن است
- layout صفحه کمتر دچار پرش میشود
- تجربه کلی حرفهایتر بهنظر میرسد
برای صفحه پروژهها، skeleton کارت پروژه معمولاً از spinner یا متن ساده بهتر است.
برای بلاگ، skeleton لیست پستها یا محتوای article منطقیتر است.
یعنی loading state باید تا حد ممکن شبیه ساختار نهایی همان صفحه باشد.
ساخت error.tsx
وقتی یک خطای غیرمنتظره داخل route رخ میدهد، Next.js میتواند با error.tsx یک fallback UI نمایش دهد.
مثلاً:
// app/projects/error.tsx
'use client'
import { useEffect } from 'react'
export default function Error({
error,
unstable_retry,
}: {
error: Error & { digest?: string }
unstable_retry: () => void
}) {
useEffect(() => {
console.error(error)
}, [error])
return (
<section className="space-y-4">
<h2>Something went wrong</h2>
<p>We could not load the projects right now.</p>
<button onClick={() => unstable_retry()}>Try again</button>
</section>
)
}
چند نکته مهم:
error.tsxباید Client Component باشد، پس به'use client'نیاز دارد- این فایل نقش
Error Boundaryرا برای همان segment بازی میکند - با
unstable_retry()میتوان تلاش دوباره برای render و fetch انجام داد
error.tsx چه خطاهایی را میگیرد؟
این فایل برای uncaught exceptions است؛ یعنی خطاهای غیرمنتظرهای که نباید جزو جریان عادی برنامه باشند.
مثلاً:
- خطای runtime در render
- شکست غیرمنتظره در لایه داده
- باگ در منطق یک کامپوننت
اگر خطا از یکی از فرزندان همین segment رخ دهد، نزدیکترین error.tsx آن را میگیرد.
این رفتار باعث میشود بتوانی error handling را بهصورت granular در سطوح مختلف route hierarchy تعریف کنی.
یک نکته مهم درباره production
طبق داک رسمی، خطاهایی که از Server Component به client فرستاده میشوند در production معمولاً پیام خام سرور را نشان نمیدهند.
بهجای آن، یک پیام عمومی و digest داده میشود تا جزئیات حساس نشت نکند.
پس بهتر است UI خطا را بر پایه پیامهای عمومی و انسانی طراحی کنی، نه بر اساس نمایش مستقیم error.message.
پیامهای خطا
یک اشتباه رایج این است که error.tsx فقط چیزی شبیه Something went wrong نشان دهد و تمام.
این بهتر از crash کامل است، اما برای UX کافی نیست.
پیام خطا باید:
- کوتاه باشد
- روشن باشد
- از نظر لحن با بقیه محصول هماهنگ باشد
- کار بعدی کاربر را مشخص کند
مثلاً برای صفحه پروژهها:
We could not load the projects right now.Please try again in a moment.
برای فرمها یا server actionها هم باید بین خطای مورد انتظار و خطای غیرمنتظره تفاوت بگذاریم.
طبق داک رسمی، در خطاهای مورد انتظار، مخصوصاً در Server Functions، بهتر است بهجای throw کردن، نتیجه خطا را بهعنوان return value برگردانیم و در UI نمایش بدهیم.
مثال مفهومی:
'use server'
export async function createPost(prevState: any, formData: FormData) {
const title = formData.get('title')
if (!title) {
return { message: 'Title is required.' }
}
return { message: '' }
}
و بعد در فرم:
'use client'
import { useActionState } from 'react'
import { createPost } from '@/app/actions'
const initialState = {
message: '',
}
export function PostForm() {
const [state, formAction, pending] = useActionState(createPost, initialState)
return (
<form action={formAction}>
<input name="title" />
{state.message ? <p aria-live="polite">{state.message}</p> : null}
<button disabled={pending}>Create</button>
</form>
)
}
این بخش مهم است، چون همه خطاها قرار نیست به error.tsx بروند.
بعضی خطاها بخشی از رفتار عادی سیستم هستند و باید مستقیم در UI مدیریت شوند.
ساخت not-found.tsx
وقتی route پیدا شده، اما منبعی که صفحه به آن وابسته است وجود ندارد، باید از notFound() استفاده کنیم و UI مناسب را در not-found.tsx نمایش بدهیم.
مثلاً در صفحه جزئیات یک پست:
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { getPostBySlug } from '@/lib/data/posts'
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const post = await getPostBySlug(slug)
if (!post) {
notFound()
}
return <article>{post.title}</article>
}
و بعد:
// app/blog/[slug]/not-found.tsx
import Link from 'next/link'
export default function NotFound() {
return (
<section className="space-y-4">
<h1>Post not found</h1>
<p>The requested article does not exist.</p>
<Link href="/blog">Back to blog</Link>
</section>
)
}
چه زمانی از notFound() استفاده کنیم؟
وقتی:
slugمعتبر نیست- رکوردی در دیتابیس پیدا نشده
- محتوای publish نشده نباید برای route عمومی نمایش داده شود
این حالت با error.tsx فرق دارد.
در notFound() مشکلی در اجرای برنامه رخ نداده؛ فقط منبع موردنظر وجود ندارد یا قابل نمایش نیست.
نکته مهم درباره status code
طبق داک رسمی، در حالت streaming ممکن است پاسخ نهایی با status 200 برگردد، حتی اگر محتوای not-found نمایش داده شود.
در این شرایط Next.js از noindex برای جلوگیری از ایندکس شدن URL استفاده میکند.
برای بیشتر پروژههای محتوایی این رفتار مشکلی ایجاد نمیکند، اما خوب است بدانیم که در routeهای streamشده، نمایش 404 UI همیشه بهمعنای 404 HTTP status نیست.
تفاوت loading، error و not-found
اگر بخواهیم خیلی خلاصه فرق این سه state را روشن کنیم:
loading.tsx: وقتی داده یا UI هنوز آماده نشدهerror.tsx: وقتی خطای غیرمنتظره باعث شکست render یا fetch شدهnot-found.tsx: وقتی route یا resource موردنظر وجود ندارد
این تفکیک مهم است، چون هرکدام معنای متفاوتی در UX دارند.
مثلاً برای یک صفحه پروژه با slug:
- اگر داده هنوز در حال دریافت است:
loading - اگر query با خطای غیرمنتظره fail شود:
error - اگر پروژهای با آن
slugوجود نداشته باشد:not-found
جمعبندی
در Next.js با App Router، مدیریت stateهای واقعی صفحه فقط به data fetching ختم نمیشود.
برای اینکه routeها حرفهای و قابلاعتماد باشند، باید از conventionهای خود framework استفاده کنیم:
loading.tsxبرای نمایش fallback هنگام load شدن routeerror.tsxبرای خطاهای غیرمنتظره و recovery باunstable_retry()not-found.tsxبرای زمانی که منبع موردنظر وجود ندارد- و
skeletonها برای loading UI معنادارتر
نتیجه این کار فقط بهتر شدن ظاهر صفحه نیست.
در عمل، ساختار routeها خواناتر میشود، UX طبیعیتر میشود و پروژه برای رشد بعدی آمادهتر میماند.