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>
);
}
useSyncExternalStore و ویجتهای پویا
§ useSyncExternalStore و ویجتهای پویا
شش ویجت که دادهشان با تأخیر و بهصورت پلکانی میرسد؛ یک نسخه با Context در هر آپدیت همهی کارتها را دوباره رندر میکند، نسخهی دیگر با useSyncExternalStore فقط همان کارتی را که دادهاش عوض شده. عدد گوشهی هر کارت تعداد رندرهای آن را نشان میدهد.
پیشنمایش
Context
بازدید
#1فروش
#1کاربر آنلاین
#1خطا
#1تأخیر
#1ثبتنام
#1useSyncExternalStore
بازدید
#1فروش
#1کاربر آنلاین
#1خطا
#1تأخیر
#1ثبتنام
#1خروجی
هنوز چیزی ثبت نشده.
کد