دادهٔ زنده (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():

Prop

Type

LiveStatus

خروجی useLiveRefresh() که به pageState({ live }) داده می‌شود (هوک دادهٔ دیگر همین را از فیلدهای خودش می‌سازد):

Prop

Type

دسترسی‌پذیری

  • زمان در <time> است (زمان کامل در dateTime و عنوان نشانگر) و همان‌جا که هست خوانده می‌شود؛ هر تیک ساعت یا هر تازه‌شدن موفق اعلام نمی‌شود.
  • یک ناحیهٔ زندهٔ مؤدبانه (role="status") فقط دو چیز را اعلام می‌کند: «به‌روزرسانی داده‌ها انجام نشد» و، وقتی داده برگشت، «داده‌ها دوباره به‌روز شد». اعلان هشدار role="note" است، نه alert: شکست یک بار اعلام می‌شود.
  • دکمهٔ به‌روزرسانی نام دارد («به‌روزرسانی داده‌ها»، و هنگام تازه‌شدن «در حال به‌روزرسانی داده‌ها»).
  • برچسب «آخرین به‌روزرسانی» حتی وقتی دیده نمی‌شود (گوشی، پنل، نمودار) برای صفحه‌خوان خوانده می‌شود.
  • کهنه بودن داده با شکل (آیکون هشدار) هم نشان داده می‌شود، نه فقط رنگ.
  • وقتی «تلاش مجدد» اعلان موفق می‌شود و اعلان می‌رود، فوکوس به صفحه نمی‌افتد: به دکمهٔ به‌روزرسانی نشانگر می‌رود.

کامپوننت‌های مرتبط