دادهٔ زنده (useLiveRefresh)
صفحهای که دادهاش خودبهخود تازه میشود — تازهشدن بی اسکلت و بی جابهجایی، زمان آخرین بهروزرسانی، و شکستی که هرگز داده را پاک نمیکند
معرفی
بعضی صفحهها دادهای دارند که خودش عوض میشود: وضعیت کانالهای پخش، صف پردازش، پیشرفت یک کار، داشبورد عملیات. این صفحهها هر چند ثانیه داده را دوباره میگیرند. سیستم طراحی برای این کار یک مدل دارد که قالبهای صفحه خودشان اجرا میکنند:
- تازهشدن بیصدا — درخواست تکراری همان داده (هر چند ثانیه، یا با دکمهٔ بهروزرسانی) محتوای روی صفحه را همانطور که هست نگه میدارد: نه اسکلت، نه کمرنگ شدن، نه اعلام «در حال بارگذاری»؛ جای اسکرول و ردیفهای انتخابشده هم میماند. فقط نشانگر زمان، تازهشدن را نشان میدهد.
- زمان آخرین بهروزرسانی — نشانگر «آخرین بهروزرسانی: 12 ثانیه پیش» با دکمهٔ بهروزرسانی، اولین اقدام صفحه (یا ردیف عنوان یک بخش، پنل یا نمودار). نشانگر را قالب میسازد؛ کامپوننتی برای گذاشتن دستی ندارد.
- شکست بیخطر — اگر یکی از تازهشدنها شکست بخورد، داده روی صفحه میماند و یک اعلان هشدار با «تلاش مجدد» بالای
آن میآید؛
ErrorStateفقط وقتی جای محتوا را میگیرد که هیچ دادهای نیست (اولین بارگذاری شکست خورده) یا خود درخواست عوض شده (بازه یا فیلتر دیگری) و شکست خورده است. - دادهٔ کهنه — دادهای که تازهشدنش شکست خورده یا دو نوبت تازه نشده است «کهنه» است: نشانگر بهجای نقطه، آیکون هشدار نشان میدهد (شکل، نه فقط رنگ).
همهٔ اینها از یک ورودی میآید: state={pageState({ …, live })}، که live خروجی هوک useLiveRefresh است.
چه زمانی استفاده کنیم:
- صفحه یا بخشی که دادهاش بدون کار کاربر عوض میشود و کاربر باید تازه بودنش را بداند: پایش زنده، صفها و کارها، داشبورد عملیات، پیشرفت پردازش.
- صفحهای که دادهاش گران است و فقط با درخواست کاربر تازه میشود، ولی کاربر باید بداند داده مال کِی است: همان هوک
بی
every.
چه زمانی استفاده نکنیم:
- فهرست یا داشبورد معمولی که دادهاش با تغییر فیلتر، بازه یا صفحه عوض میشود: همان
pageStateبیliveکافی است (ردیفها تا رسیدن پاسخ کمرنگ میمانند). - فرم (
FormPage،SettingsSection) وEntityDrawer: هرگز زنده نیستند (پایینتر). - دادهٔ ارسالی از سرور (SSE یا WebSocket): این هوک درخواست تکراری میفرستد و دادهٔ ارسالی را پوشش نمیدهد؛ آن را بهعنوان DS-GAP ثبت کنید.
- نشانگر زمانی از خودتان، اسپینر روی جدول یا بنر خطا: قالب همه را از
stateمیسازد.
استفاده
صفحه همان الگوی همیشگی داده را دارد (useAsync و load و افکتی که load را صدا میزند) و فقط useLiveRefresh را
اضافه میکند و خروجیاش را به pageState میدهد. every بر حسب ثانیه است، از یک مجموعهٔ بسته:
'use client'
import * as React from 'react'
import { DataTable, useAsync } from '@partodata/ui'
import { ListPage, pageState, useLiveRefresh } from '@partodata/ui/templates'
type Channel = { id: string; name: string; status: string }
async function getChannels(): Promise<Channel[]> {
const response = await fetch('/api/channels')
if (!response.ok) throw new Error(`channels: ${response.status}`)
return response.json()
}
const columns = [
{ id: 'name', header: 'کانال', cell: (row: Channel) => row.name },
{ id: 'status', header: 'وضعیت', cell: (row: Channel) => row.status },
]
export function ChannelsScreen() {
const { data, isLoading, error, run } = useAsync<Channel[]>()
// با useCallback: load تازه یعنی درخواست تازه، نه تازهشدن
const load = React.useCallback(() => run(() => getChannels()), [run])
// افکت خود صفحه میماند: هوک فقط تازه میکند
React.useEffect(() => {
load()
}, [load])
// هر 15 ثانیه، فقط وقتی زبانهٔ مرورگر دیده میشود
const live = useLiveRefresh({ load, isLoading, error, every: 15 })
return (
<ListPage
title="کانالها"
description="وضعیت دریافت کانالهای پایششده"
state={pageState({
data,
isLoading,
error,
onRetry: load,
live,
emptyCopy: { title: 'هنوز کانالی افزوده نشده است' },
})}
>
<DataTable columns={columns} data={data ?? []} />
</ListPage>
)
}قاعدهٔ تازهشدن (polling)
| رویداد | آنچه کاربر میبیند |
|---|---|
| اولین بارگذاری | اسکلت همشکل محتوا |
تازهشدن همان درخواست (هر every ثانیه یا با دکمه) | همان محتوا، بی تغییر؛ آیکون دکمهٔ بهروزرسانی میچرخد |
| پاسخ تازه رسید | محتوا در جای خود عوض میشود؛ اسکرول و انتخاب میماند؛ زمان «همین حالا» میشود |
| تازهشدن شکست خورد | همان محتوا + اعلان هشدار «بهروزرسانی دادهها انجام نشد» با «تلاش مجدد»؛ آیکون هشدار |
| تازهشدن بعدی موفق شد | اعلان میرود؛ صفحهخوان «دادهها دوباره بهروز شد» را میشنود |
| اولین بارگذاری شکست خورد (هیچ دادهای نیست) | ErrorState با «تلاش مجدد»؛ هوک با همان دوره بیصدا دوباره میکوشد و خطا تا رسیدن پاسخ روی صفحه میماند |
| کاربر بازه، فیلتر یا صفحه را عوض کرد | درخواست تازه است، نه تازهشدن: ردیفهای قبلی کمرنگ؛ نشانگر سر جایش میماند |
| درخواست تازه شکست خورد | ErrorState؛ تا «تلاش مجدد» آن موفق نشود، تازهشدن خودکار نیست و ردیفهای درخواست قبلی برنمیگردند |
| زبانهٔ مرورگر پنهان است | تازهشدن متوقف است؛ با برگشتن به زبانه، اگر نوبتش گذشته باشد، بیدرنگ تازه میشود |
- دوره از شروع هر درخواست شمرده میشود: پاسخی که سه ثانیه طول بکشد، تازهشدن بعدی را یک دورهٔ کامل عقب نمیاندازد.
- هیچوقت دو درخواست همزمان نیست: تازهشدنی که نوبتش رسیده ولی درخواستی در جریان است، شروع نمیشود.
every، برای هر نیاز یک مقدار
| نیاز | every |
|---|---|
| کار در حال اجرا (صفحه پیشرفتش را نشان میدهد) | done ? undefined : 2 |
| کانال یا صف زنده | 5 |
| لاگ زنده | 10 |
| فهرست یا داشبورد عملیات | 15 |
| صفحهٔ وضعیت سامانه (سلامت، ظرفیت) | 30 |
| وضعیت کند (سهمیهٔ روزانه) | 60 |
| داشبورد گزارش | 300 |
| دادهٔ گران، فقط با درخواست کاربر | بی every |
صفحهای که مدتی چیزی در آن عوض نشده، با every کندتر عقب میکشد (مثلاً 15 به 60): هوک با هر every تازه زمانسنجش
را از نو تنظیم میکند.
جای نشانگر و اعلان
| قالب یا بخش | نشانگر | اعلان شکست |
|---|---|---|
ListPage، DetailPage، DashboardPage، SettingsPage، CustomPage | اولین اقدام سرِ صفحه (در داشبورد پیش از بازه) | آخرین ردیف سرِ صفحه، بالای محتوا |
ListPage با نوار ابزار (جستوجو یا فیلتر) | اولین اقدام نوار ابزار چسبان؛ با اسکرول فهرست روی صفحه میماند | آخرین ردیف سرِ صفحه |
DetailSection با state خودش | اولین اقدام ردیف عنوان بخش | بالای محتوای بخش |
PagePane با state خودش | فشرده (برچسب فقط برای صفحهخوان) در ردیف عنوان پنل | بالای بدنهٔ پنل |
DashboardChart با state خودش | فشرده در سرِ کارت نمودار | ندارد (نمودار دادهاش را نگه میدارد؛ آیکون هشدار و اعلام صفحهخوان) |
CustomPage layout="fill" titleHidden (دیوار نمایش) | ندارد (سرِ صفحهای نیست) | بالای محتوا، با اعلام صفحهخوان |
PageState | ندارد (بلوکی بی ردیف عنوان) | بالای محتوای بلوک؛ ناحیهٔ وضعیتش شکست را اعلام میکند |
- زیر
42remاز عرض صفحه (گوشی)، برچسب «آخرین بهروزرسانی:» فقط برای صفحهخوان است و نقطه، زمان و دکمه میمانند. - پنلی باریک که عنوان و اقدامهایش در یک ردیف جا نشود، اقدامها را به ردیف دوم میبرد و عنوان را از بین نمیبرد.
- هنگام بارگذاری درخواست تازه یا شکستش، نشانگر سر جایش میماند (زمانش همان زمان آخرین دادهای است که رسید)؛ اعلان فقط روی محتوایی که مانده است.
هر درخواست، یک live
یک useLiveRefresh یعنی یک درخواست، و live آن فقط به یک state میرود: یا state صفحه، یا state همان یک بخشی
که جدا از صفحه بارگذاری میشود (DetailSection، DashboardChart یا PagePane با درخواست و هوک خودش). live صفحه را
به state یک بخش هم ندهید: دو نشانگر، دو اعلان و دو دکمهٔ «تلاش مجدد» برای یک درخواست میشد. اگر بدهید، در محیط توسعه
هشدار میگیرید و آن بخش چیزی از خودش نشان نمیدهد.
آنچه هرگز زنده نیست
- فرم (
FormPage،SettingsSection): تازهشدن، فیلدها را زیر دست کاربر و روی ویرایشهای ذخیرهنشده دوباره پر میکند. EntityDrawer: نگاهی به یک ردیفِ فهرستی است که خودش تازه میشود؛liveمالstateصفحه است.- فهرست با
loadMore: تازهشدن، دستهٔ اول را دوباره میگیرد و ردیفهای «نمایش بیشتر» و جای اسکرول از دست میرود. فهرست زنده باpaginationصفحهبندی میشود (loadهمان صفحهٔ جاری است).
هر سه در محیط توسعه هشدار میدهند.
هوک دادهٔ دیگر (TanStack Query)
useAsync با useLiveRefresh راه اصلی است. محصولی که دادهٔ صفحهاش با TanStack Query میآید، همان شیء live را از
فیلدهای کوئری میسازد و useLiveRefresh را کنارش نمیگذارد (دو بار درخواست میرفت): کوئری خودش با refetchInterval
تازه میشود و در زبانهٔ پنهان میایستد.
'use client'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { DataTable } from '@partodata/ui'
import { ListPage, pageState, type LiveInterval, type LiveStatus } from '@partodata/ui/templates'
// getChannels و columns همان نمونهٔ بالا هستند
const every: LiveInterval = 15
export function ChannelsScreen({ period }: { period: string }) {
const q = useQuery({
queryKey: ['channels', period],
queryFn: () => getChannels(period),
refetchInterval: every * 1000, // میلیثانیه برای کوئری؛ every بر حسب ثانیه
placeholderData: keepPreviousData,
})
const live: LiveStatus = {
updatedAt: q.dataUpdatedAt || undefined, // پیش از اولین داده صفر است
every,
// کلید تازه (بازهٔ دیگر) تازهشدن نیست، با placeholderData هم
refreshing: q.isRefetching && !q.isPlaceholderData,
failed: q.isRefetchError,
refresh: () => void q.refetch(),
}
return (
<ListPage
title="کانالها"
state={pageState({
data: q.data,
isLoading: q.isFetching,
error: q.error,
onRetry: () => void q.refetch(),
live,
})}
>
<DataTable columns={columns} data={q.data ?? []} />
</ListPage>
)
}refreshing تا وقتی کلید تازه در جریان است false میماند؛ برای همین ردیفهای قبلی کمرنگ میشوند و شکستش ErrorState
است، همان قاعدهٔ بالا.
حالتها و انواع
نشانگر سه حالت دارد (data-state):
fresh— نقطهٔ برند (صفحهای که خودش تازه میشود) یا نقطهٔ خنثی (فقط با درخواست)؛refreshing— تازهشدن در جریان: نقطه آرام میتپد و آیکون دکمه میچرخد (با «کاهش حرکت» سیستم، ثابت میماند)؛stale— تازهشدن شکست خورده یا دو نوبت نرسیده: آیکون هشدار بهجای نقطه، و عنوانش «دادهها بهروز نیست».
زمان نسبی در گامهای پنجثانیهای جلو میرود («همین حالا»، «15 ثانیه پیش»، «3 دقیقه پیش»، «2 ساعت پیش») و رقمهایش را
قلم فارسی میکند (رقم لاتین در متن، ss01 در قلم)؛ در عربی، شمار با اسمش مطابقت دارد («قبل دقيقتين»، «قبل 3 دقائق»).
زمان در جعبهای با کمینهٔ عرض ثابت است، پس با عوض شدن متن، نه برچسب جابهجا میشود و نه دکمه. در چاپ، زمان کامل بهجای
زمان نسبی میآید و دکمهها چاپ نمیشوند.
راهنمای استفاده
بکنید
useLiveRefresh({ load, isLoading, error, every })را کنار همان افکتloadبنویسید،loadرا باReact.useCallbackبسازید و خروجی هوک را کامل بهpageState({ …, live })بدهید.everyرا از جدول بالا انتخاب کنید و بر حسب ثانیه بنویسید.- بخشی که جدا از صفحه تازه میشود (صف یک کانال در صفحهٔ جزئیاتش)
DetailSectionبا درخواست، هوک وstateخودش است.
نکنید
- برای دادهٔ صفحه
setIntervalاز خودتان ننویسید: هوک در زبانهٔ پنهان متوقف میشود و دو درخواست را همزمان نمیفرستد. (نظرسنجی سراسری برنامه که باید در زبانهٔ پنهان هم کار کند، مثل نشانِ صف کارها، دادهٔ صفحه نیست و کد خود محصول است.) - افکت
loadصفحه را حذف نکنید: هوک فقط تازه میکند و بی آن صفحه هرگز بارگذاری نمیشود (در محیط توسعه هشدار). - برای تازهشدن، اسکلت، اسپینر یا کمرنگ کردن اضافه نکنید و فهرست را از نو mount نکنید (کلید تازه): اسکرول و انتخاب از دست میرود.
- خطای تازهشدن را با
toastیا بنر خودتان نشان ندهید؛ اعلان قالب همان است. - یک
liveرا به دوstateندهید، و به فرم،EntityDrawerیا فهرست باloadMoreندهید. everyرا میلیثانیه ننویسید (15000): خطای نوع است.
Props
UseLiveRefreshOptions
ورودی useLiveRefresh():
LiveStatus
خروجی useLiveRefresh() که به pageState({ live }) داده میشود (هوک دادهٔ دیگر همین را از فیلدهای خودش میسازد):
دسترسیپذیری
- زمان در
<time>است (زمان کامل درdateTimeو عنوان نشانگر) و همانجا که هست خوانده میشود؛ هر تیک ساعت یا هر تازهشدن موفق اعلام نمیشود. - یک ناحیهٔ زندهٔ مؤدبانه (
role="status") فقط دو چیز را اعلام میکند: «بهروزرسانی دادهها انجام نشد» و، وقتی داده برگشت، «دادهها دوباره بهروز شد». اعلان هشدارrole="note"است، نهalert: شکست یک بار اعلام میشود. - دکمهٔ بهروزرسانی نام دارد («بهروزرسانی دادهها»، و هنگام تازهشدن «در حال بهروزرسانی دادهها»).
- برچسب «آخرین بهروزرسانی» حتی وقتی دیده نمیشود (گوشی، پنل، نمودار) برای صفحهخوان خوانده میشود.
- کهنه بودن داده با شکل (آیکون هشدار) هم نشان داده میشود، نه فقط رنگ.
- وقتی «تلاش مجدد» اعلان موفق میشود و اعلان میرود، فوکوس به صفحه نمیافتد: به دکمهٔ بهروزرسانی نشانگر میرود.
کامپوننتهای مرتبط
PageState— قاعدهٔpageStateکهliveرا میخواند.ListPage،DashboardPage،DetailPage— قالبهایی که نشانگر و اعلان را جای میدهند.