برنتی / می‌نویسم که فکر کنم

← آزمایش‌ها

useSyncExternalStore و ویجت‌های پویا

§ useSyncExternalStore و ویجت‌های پویا

شش ویجت که داده‌شان با تأخیر و به‌صورت پلکانی می‌رسد؛ یک نسخه با Context در هر آپدیت همه‌ی کارت‌ها را دوباره رندر می‌کند، نسخه‌ی دیگر با useSyncExternalStore فقط همان کارتی را که داده‌اش عوض شده. عدد گوشه‌ی هر کارت تعداد رندرهای آن را نشان می‌دهد.

پیش‌نمایش

Context

بازدید

#1

فروش

#1

کاربر آنلاین

#1

خطا

#1

تأخیر

#1

ثبت‌نام

#1

useSyncExternalStore

بازدید

#1

فروش

#1

کاربر آنلاین

#1

خطا

#1

تأخیر

#1

ثبت‌نام

#1

خروجی

هنوز چیزی ثبت نشده.

کد

import {
  createContext,
  memo,
  useCallback,
  useContext,
  useEffect,
  useRef,
  useState,
  useSyncExternalStore,
} from "react";
import { Button } from "@/components/ui/button.tsx";
import { Typography } from "@/components/ui/typography.tsx";
import { useLogger } from "@/components/pages/tests/experiment.tsx";
import { cn } from "@/utils/classnames.ts";

type WidgetId = "views" | "sales" | "users" | "errors" | "latency" | "signups";

const WIDGETS: { id: WidgetId; label: string; delay: number }[] = [
  { id: "views", label: "بازدید", delay: 150 },
  { id: "sales", label: "فروش", delay: 300 },
  { id: "users", label: "کاربر آنلاین", delay: 450 },
  { id: "errors", label: "خطا", delay: 600 },
  { id: "latency", label: "تأخیر", delay: 750 },
  { id: "signups", label: "ثبت‌نام", delay: 900 },
];

function randomValue() {
  return Math.floor(Math.random() * 900) + 100;
}

function emptyValues() {
  return Object.fromEntries(WIDGETS.map((w) => [w.id, null])) as Record<
    WidgetId,
    number | null
  >;
}

// Fine-grained external store: notifies every subscriber, but each
// subscriber's own getSnapshot only changes reference when *its* id updates —
// useSyncExternalStore bails out of re-rendering the ones that didn't.
class WidgetStore {
  private values = new Map<WidgetId, number | null>(
    WIDGETS.map((w) => [w.id, null]),
  );
  private listeners = new Set<() => void>();

  subscribe = (listener: () => void) => {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  };

  getSnapshot = (id: WidgetId) => this.values.get(id) ?? null;

  set(id: WidgetId, value: number) {
    this.values.set(id, value);
    this.listeners.forEach((listener) => listener());
  }

  reset() {
    WIDGETS.forEach((w) => this.values.set(w.id, null));
    this.listeners.forEach((listener) => listener());
  }
}

// Anti-pattern under test: the whole map lives in context, so every
// consumer re-renders whenever *any* widget's value changes.
const WidgetDataContext = createContext<Record<WidgetId, number | null> | null>(
  null,
);

// Ref-based render counter for the demo's badges. Rendering is meant to be a
// pure calculation, so mutating a ref here is instrumentation-only — never do
// this in real widget code. Under React's Strict Mode, dev renders are
// double-invoked, so counts are inflated 2x in development; the ratio between
// the two columns still holds.
function useRenderCount() {
  const ref = useRef(0);
  ref.current += 1;
  return ref.current;
}

function WidgetShell({
  testId,
  label,
  value,
  renderCount,
}: {
  testId: string;
  label: string;
  value: number | null;
  renderCount: number;
}) {
  const [justUpdated, setJustUpdated] = useState(false);
  const mounted = useRef(false);

  useEffect(() => {
    if (!mounted.current) {
      mounted.current = true;
      return;
    }
    if (value === null) return;
    setJustUpdated(true);
    const timer = setTimeout(() => setJustUpdated(false), 700);
    return () => clearTimeout(timer);
  }, [value]);

  return (
    <div
      data-testid={testId}
      className="rounded-lg border border-rule bg-background p-3 flex flex-col gap-1.5"
    >
      <div className="flex items-center justify-between gap-2">
        <Typography variant="caption" className="text-[.8rem]">
          {label}
        </Typography>
        <span
          data-testid={`${testId}-count`}
          className="font-mono text-[.7rem] text-faint shrink-0"
        >
          #{renderCount}
        </span>
      </div>
      {value === null ? (
        <div className="h-6 w-14 rounded bg-rule/60 animate-pulse" />
      ) : (
        <span
          className={cn(
            "font-mono text-lg transition-colors duration-700",
            justUpdated ? "text-accent" : "text-text",
          )}
        >
          {value}
        </span>
      )}
    </div>
  );
}

function ContextWidgetCard({
  id,
  label,
  onRender,
}: {
  id: WidgetId;
  label: string;
  onRender: (id: WidgetId, count: number) => void;
}) {
  const data = useContext(WidgetDataContext)!;
  const renderCount = useRenderCount();

  useEffect(() => {
    onRender(id, renderCount);
  });

  return (
    <WidgetShell
      testId={`context-${id}`}
      label={label}
      value={data[id]}
      renderCount={renderCount}
    />
  );
}

// memo() matters here: useSyncExternalStore only skips a re-render that its
// *own* subscription would trigger unnecessarily. It does nothing to stop a
// re-render forced by the parent (ExternalStoreDemo re-renders on every
// context update too) — without memo, every card would still re-render
// alongside the context list, just with a subscription that never fires.
const StoreWidgetCard = memo(function StoreWidgetCard({
  id,
  label,
  store,
  onRender,
}: {
  id: WidgetId;
  label: string;
  store: WidgetStore;
  onRender: (id: WidgetId, count: number) => void;
}) {
  const getSnapshot = useCallback(() => store.getSnapshot(id), [store, id]);
  const value = useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
  const renderCount = useRenderCount();

  useEffect(() => {
    onRender(id, renderCount);
  });

  return (
    <WidgetShell
      testId={`store-${id}`}
      label={label}
      value={value}
      renderCount={renderCount}
    />
  );
});

export function ExternalStoreDemo() {
  const { log, logTable } = useLogger();
  const [dataById, setDataById] =
    useState<Record<WidgetId, number | null>>(emptyValues);
  const storeRef = useRef<WidgetStore | null>(null);
  if (!storeRef.current) storeRef.current = new WidgetStore();
  const store = storeRef.current;

  const [runId, setRunId] = useState(0);
  const contextRenderCounts = useRef<Partial<Record<WidgetId, number>>>({});
  const storeRenderCounts = useRef<Partial<Record<WidgetId, number>>>({});

  const reportContextRender = useCallback((id: WidgetId, count: number) => {
    contextRenderCounts.current[id] = count;
  }, []);
  const reportStoreRender = useCallback((id: WidgetId, count: number) => {
    storeRenderCounts.current[id] = count;
  }, []);

  useEffect(() => {
    log("لیست ویجت‌ها نمایش داده شد؛ داده‌ی هرکدام با تأخیر و پلکانی می‌رسد");

    const timers = WIDGETS.map((w) =>
      setTimeout(() => {
        const value = randomValue();
        setDataById((prev) => ({ ...prev, [w.id]: value }));
        store.set(w.id, value);
      }, w.delay),
    );

    return () => timers.forEach(clearTimeout);
  }, [runId, store, log]);

  // Reset synchronously, before the remount (key change) below, so the fresh
  // cards are born reading null — not whatever the previous run left behind.
  // Doing this in an effect after the remount would be one render too late.
  const handleRestart = () => {
    store.reset();
    setDataById(emptyValues());
    setRunId((n) => n + 1);
  };

  useEffect(() => {
    const sum = (counts: Partial<Record<WidgetId, number>>) =>
      Object.values(counts).reduce((total: number, n) => total + (n ?? 0), 0);

    logTable(
      [
        { approach: "Context", renders: sum(contextRenderCounts.current) },
        { approach: "Store", renders: sum(storeRenderCounts.current) },
      ],
      "totals",
    );
  });

  return (
    <div className="flex flex-col gap-6">
      <div className="flex justify-end">
        <Button variant="secondary" onClick={handleRestart}>
          اجرای دوباره
        </Button>
      </div>

      <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
        <div className="flex flex-col gap-3 min-w-0">
          <Typography variant="faint">Context</Typography>
          <WidgetDataContext.Provider value={dataById}>
            <div
              id="context-list"
              className="grid grid-cols-2 sm:grid-cols-3 gap-2"
            >
              {WIDGETS.map((w) => (
                <ContextWidgetCard
                  key={`${w.id}-${runId}`}
                  id={w.id}
                  label={w.label}
                  onRender={reportContextRender}
                />
              ))}
            </div>
          </WidgetDataContext.Provider>
        </div>

        <div className="flex flex-col gap-3 min-w-0">
          <Typography variant="faint">useSyncExternalStore</Typography>
          <div
            id="store-list"
            className="grid grid-cols-2 sm:grid-cols-3 gap-2"
          >
            {WIDGETS.map((w) => (
              <StoreWidgetCard
                key={`${w.id}-${runId}`}
                id={w.id}
                label={w.label}
                store={store}
                onRender={reportStoreRender}
              />
            ))}
          </div>
        </div>
      </div>
    </div>
  );
}