closure در React یکی از مفاهیم بنیادین زبان جاوااسکریپت است که درک دقیق آن، نقش بسیار مهمی در توسعه مؤثر و بدون خطای رابط‌های کاربری دارد. این مفهوم می‌تواند منجر به بروز رفتارهای غیرمنتظره در اپلیکیشن‌های React شود، به‌ویژه زمانی که با state یا callbackها درگیر هستیم.

در این مقاله، ضمن تعریف دقیق closure و نحوه عملکرد آن، با ذکر مثال‌هایی ساده و همچنین یک سناریوی واقعی در محیط production، چالش‌های رایج مربوط به closure در React را بررسی کرده و راهکارهایی برای مدیریت آن ارائه می‌دهیم.

دسترسی به کدهای مرتبط با مثال‌های این مقاله از طریق این لینک امکان‌پذیر می‌باشد.

Closure در جاوااسکریپت چیست؟

Closure رابطه‌ای است که بین یک تابع در جاوااسکریپت و متغیرهای در دسترس از محیط بیرونی آن تابع ایجاد می‌شود. در جاوااسکریپت، متغیرها دارای مفهومی به نام scope هستند که مشخص می‌کند هر مقدار، در کدام بخش‌های کد قابل استفاده است. این ساختار دسترسی با اصطلاح lexical scope نیز شناخته می‌شود.

جاوااسکریپت به‌طور کلی سه سطح اصلی از scope را ارائه می‌دهد:

در ادامه مثالی ساده از scope در کد را داریم:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
// Global Scope
let globalValue = "available anywhere";
// Function Scope
function yourFunction() {
// var1 and var2 are only accessible in this function
let var1 = "hello";
let var2 = "world";
console.log(var1);
console.log(var2);
}
// Block Scope
if(globalValue = "available anywhere") {
// variables defined here are only accssible inside this conditional
let b1 = "block 1";
let b2 = "block 2";
}
// Global Scope let globalValue = "available anywhere"; // Function Scope function yourFunction() { // var1 and var2 are only accessible in this function let var1 = "hello"; let var2 = "world"; console.log(var1); console.log(var2); } // Block Scope if(globalValue = "available anywhere") { // variables defined here are only accssible inside this conditional let b1 = "block 1"; let b2 = "block 2"; }
// Global Scope
let globalValue = "available anywhere";

// Function Scope
function yourFunction() { 
  // var1 and var2 are only accessible in this function
  let var1 = "hello";
  let var2 = "world";

  console.log(var1);
  console.log(var2);
}

// Block Scope
if(globalValue = "available anywhere") {
  // variables defined here are only accssible inside this conditional
  let b1 = "block 1";
  let b2 = "block 2";
}

در مثال بالا:

closure زمانی شکل می‌گیرد که تابعی بتواند به متغیرهایی خارج از دامنه‌ معمولش دسترسی داشته باشد. در جاوااسکریپت، این امکان وجود دارد که توابع به متغیرهای موجود در scope بالا خود، حتی پس از خروج از آن محیط، دسترسی داشته باشند.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
function start() {
// variable created inside function
const firstName = "John";
// function inside the start function which has access to firstName
function displayFirstName() {
// displayFirstName creates a closure
console.log(firstName);
}
// should print "John" to the console
displayName();
}
start();
function start() { // variable created inside function const firstName = "John"; // function inside the start function which has access to firstName function displayFirstName() { // displayFirstName creates a closure console.log(firstName); } // should print "John" to the console displayName(); } start();
function start() {
  // variable created inside function
  const firstName = "John";

  // function inside the start function which has access to firstName
  function displayFirstName() {
    // displayFirstName creates a closure
    console.log(firstName);
  }
  // should print "John" to the console
  displayName();
}
start();

در پروژه‌های جاوااسکریپتی، closureها گاهی منجر به بروز رفتارهای ناخواسته‌ای می‌شوند؛ به‌ویژه زمانی که برخی متغیرها در دسترس باقی می‌مانند اما برخی دیگر نه.
اما در پروژه‌های React، این اتفاق بیشتر در هنگام مدیریت state لوکال یا هندل کردن eventها درون کامپوننت‌ها دیده می‌شود.

در صورتی که مایل به بررسی عمیق‌تر مفاهیمی همچون closure، توابع Higher-Order و تکنیک Currying هستید، مطالعه مقالات تخصصی منتشر شده در فرانت کست می‌تواند مفید باشد.

Closure در React

در پروژه‌های React، اغلب مشکلات مربوط به closure هنگام مدیریت state به وجود می‌آیند. در اپلیکیشن‌های React، می‌توان از هوک

useState
useState برای مدیریت state لوکال یک کامپوننت استفاده کرد. همچنین ابزارهایی مانند Redux برای مدیریت state متمرکز، یا React Context برای اشتراک state بین چند کامپوننت در سطح پروژه نیز در دسترس هستند.

کنترل دقیق state یک کامپوننت یا مجموعه‌ای از کامپوننت‌ها، نیازمند درک صحیحی از این است که چه مقادیری در چه محدوده‌ای قابل دسترسی هستند. هنگام مدیریت state در یک پروژه React، ممکن است با مسائلی مواجه شویم که ناشی از closure بوده و باعث ایجاد تغییرات ناهم‌خوان یا غیرقابل پیش‌بینی شوند.

یک مثال ساده از closure در React

برای توضیح بهتر مفهوم closure در React، مثالی با استفاده از تابع داخلی

setTimeout
setTimeout ارائه می‌دهیم. فرض کنید اپلیکیشنی داریم که یک مقدار ورودی را دریافت کرده و بر اساس آن یک عملیات async انجام می‌دهد. معمولاً چنین حالتی زمانی رخ می‌دهد که فرم‌ها یا ورودی‌ها، داده‌ی کاربر را گرفته و به یک API ارسال می‌کنند. اما این رفتار را می‌توانیم با استفاده از
setTimeout
setTimeout در یک کامپوننت ساده‌سازی کنیم.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
const SetTimeoutIssue = () => {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
// This will always show the value of count at the time the timeout was set
setTimeout(() => {
console.log('Current count (Issue):', count);
alert(`Current count (Issue): ${count}`);
}, ۲۰۰۰);
};
return (
<div className="p-4 bg-black rounded shadow">
<h2 className="text-xl font-bold mb-4">setTimeout Issue</h2>
<p className="mb-4">Current count: {count}</p>
<button
onClick={handleClick}
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
>
Increment and Check After 2s
</button>
<div className="mt-4 p-4 bg-gray-100 rounded">
<p className="text-black">
Expected: Alert shows the updated count
</p>
<p className="text-black">
Actual: Alert shows the count from when setTimeout was
called
</p>
</div>
</div>
);
};
const SetTimeoutIssue = () => { const [count, setCount] = useState(0); const handleClick = () => { setCount(count + 1); // This will always show the value of count at the time the timeout was set setTimeout(() => { console.log('Current count (Issue):', count); alert(`Current count (Issue): ${count}`); }, ۲۰۰۰); }; return ( <div className="p-4 bg-black rounded shadow"> <h2 className="text-xl font-bold mb-4">setTimeout Issue</h2> <p className="mb-4">Current count: {count}</p> <button onClick={handleClick} className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600" > Increment and Check After 2s </button> <div className="mt-4 p-4 bg-gray-100 rounded"> <p className="text-black"> Expected: Alert shows the updated count </p> <p className="text-black"> Actual: Alert shows the count from when setTimeout was called </p> </div> </div> ); };
const SetTimeoutIssue = () => {
    const [count, setCount] = useState(0);
    const handleClick = () => {
        setCount(count + 1);
        // This will always show the value of count at the time the timeout was set
        setTimeout(() => {
            console.log('Current count (Issue):', count);
            alert(`Current count (Issue): ${count}`);
        }, ۲۰۰۰);
    };
    return (
        <div className="p-4 bg-black rounded shadow">
            <h2 className="text-xl font-bold mb-4">setTimeout Issue</h2>
            <p className="mb-4">Current count: {count}</p>
            <button
                onClick={handleClick}
                className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
            >
                Increment and Check After 2s
            </button>
            <div className="mt-4 p-4 bg-gray-100 rounded">
                <p className="text-black">
                    Expected: Alert shows the updated count
                </p>
                <p className="text-black">
                    Actual: Alert shows the count from when setTimeout was
                    called
                </p>
            </div>
        </div>
    );
};

در نگاه اول، این کد نباید مشکلی ایجاد کند: کاربر روی دکمه‌ای کلیک می‌کند، مقدار شمارنده افزایش می‌یابد و سپس در یک modal نمایشی نمایش داده می‌شود. اما مشکل دقیقاً در این نقطه به‌وجود می‌آید:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
const handleClick = () => {
setCount(count + 1);
// This will always show the value of count at the time the timeout was set
setTimeout(() => {
console.log('Current count (Issue):', count);
alert(`Current count (Issue): ${count}`);
}, ۲۰۰۰);
};
const handleClick = () => { setCount(count + 1); // This will always show the value of count at the time the timeout was set setTimeout(() => { console.log('Current count (Issue):', count); alert(`Current count (Issue): ${count}`); }, ۲۰۰۰); };
const handleClick = () => {
        setCount(count + 1);
        // This will always show the value of count at the time the timeout was set
        setTimeout(() => {
            console.log('Current count (Issue):', count);
            alert(`Current count (Issue): ${count}`);
        }, ۲۰۰۰);
    };

تحلیل مشکل closure در مثال بالا

مقدار

count
count توسط تابع
setTimeout
setTimeout
درون یک closure ذخیره می‌شود. اگر این مثال را اجرا کنیم و چند بار پشت سر هم روی دکمه کلیک کنیم، با رفتاری به این صورت مواجه می‌شویم که مقدار نمایش داده شده در modal عدد ۰ است، در حالی که مقدار واقعی
count
count در همان لحظه برابر با ۱ است. دلیل این رفتار آن است که closure ایجاد شده توسط
setTimeout
setTimeout، مقدار اولیه متغیر را قفل کرده و به‌روز نمی‌شود.

برای حل این مشکل، می‌توانیم از هوک

useRef
useRef استفاده کنیم. این هوک یک رفرنس پایدار ایجاد می‌کند که مقدار به‌روزشده متغیر را در طول رندرهای مجدد حفظ می‌کند. در مدیریت state در React، مشکلاتی از این دست ممکن است زمانی رخ دهند که یک رندر جدید، داده‌ای را از state قبلی استخراج کند.

اگر فقط از

useState
useState استفاده می‌کنیم و منطق پیچیده‌ای نداریم، معمولاً مشکلی پیش نمی‌آید. اما وقتی closure با state درگیر می‌شود، ممکن است مقادیر را نادرست ذخیره کرده یا در زمان اشتباهی بازیابی کند. در اینجا، نسخه بازنویسی‌شده کامپوننت اولیه را می‌بینیم که مشکل closure را برطرف کرده است:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
const SetTimeoutSolution = () => {
const [count, setCount] = useState(0);
const countRef = useRef(count);
// Keep the ref in sync with the state
countRef.current = count;
const handleClickWithRef = () => {
setCount(count + 1);
// Using ref to get the latest value
setTimeout(() => {
console.log('Current count (Solution with Ref):', countRef.current);
alert(`Current count (Solution with Ref): ${countRef.current}`);
}, ۲۰۰۰);
};
return (
<div className="p-4 bg-black rounded shadow">
<h2 className="text-xl font-bold mb-4">setTimeout Solution</h2>
<p className="mb-4">Current count: {count}</p>
<div className="space-y-4">
<div>
<button
onClick={handleClickWithRef}
className="bg-green-500 text-black px-4 py-2 rounded hover:bg-green-600"
>
Increment and Check After 2s
</button>
<div className="mt-4 p-4 bg-gray-100 rounded">
<p className="text-black">
Expected: Alert shows the updated count
</p>
<p className="text-black">
Actual: Alert shows the updated count
</p>
</div>
</div>
</div>
</div>
);
};
const SetTimeoutSolution = () => { const [count, setCount] = useState(0); const countRef = useRef(count); // Keep the ref in sync with the state countRef.current = count; const handleClickWithRef = () => { setCount(count + 1); // Using ref to get the latest value setTimeout(() => { console.log('Current count (Solution with Ref):', countRef.current); alert(`Current count (Solution with Ref): ${countRef.current}`); }, ۲۰۰۰); }; return ( <div className="p-4 bg-black rounded shadow"> <h2 className="text-xl font-bold mb-4">setTimeout Solution</h2> <p className="mb-4">Current count: {count}</p> <div className="space-y-4"> <div> <button onClick={handleClickWithRef} className="bg-green-500 text-black px-4 py-2 rounded hover:bg-green-600" > Increment and Check After 2s </button> <div className="mt-4 p-4 bg-gray-100 rounded"> <p className="text-black"> Expected: Alert shows the updated count </p> <p className="text-black"> Actual: Alert shows the updated count </p> </div> </div> </div> </div> ); };
const SetTimeoutSolution = () => {
    const [count, setCount] = useState(0);
    const countRef = useRef(count);
    // Keep the ref in sync with the state
    countRef.current = count;
    const handleClickWithRef = () => {
        setCount(count + 1);
        // Using ref to get the latest value
        setTimeout(() => {
            console.log('Current count (Solution with Ref):', countRef.current);
            alert(`Current count (Solution with Ref): ${countRef.current}`);
        }, ۲۰۰۰);
    };
    return (
        <div className="p-4 bg-black rounded shadow">
            <h2 className="text-xl font-bold mb-4">setTimeout Solution</h2>
            <p className="mb-4">Current count: {count}</p>
            <div className="space-y-4">
                <div>
                    <button
                        onClick={handleClickWithRef}
                        className="bg-green-500 text-black px-4 py-2 rounded hover:bg-green-600"
                    >
                        Increment and Check After 2s
                    </button>
                    <div className="mt-4 p-4 bg-gray-100 rounded">
                        <p className="text-black">
                            Expected: Alert shows the updated count
                        </p>
                        <p className="text-black">
                            Actual: Alert shows the updated count
                        </p>
                    </div>
                </div>
            </div>
        </div>
    );
};

تفاوت کلیدی این نسخه با نسخه‌ی اولیه در این است:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
const [count, setCount] = useState(0);
const countRef = useRef(count);
// Keep the ref in sync with the state
countRef.current = count;
const handleClickWithRef = () => {
setCount(count + 1);
// Using ref to get the latest value
setTimeout(() => {
console.log('Current count (Solution with Ref):', countRef.current);
alert(`Current count (Solution with Ref): ${countRef.current}`);
}, ۲۰۰۰);
};
const [count, setCount] = useState(0); const countRef = useRef(count); // Keep the ref in sync with the state countRef.current = count; const handleClickWithRef = () => { setCount(count + 1); // Using ref to get the latest value setTimeout(() => { console.log('Current count (Solution with Ref):', countRef.current); alert(`Current count (Solution with Ref): ${countRef.current}`); }, ۲۰۰۰); };
const [count, setCount] = useState(0);
   const countRef = useRef(count);
   // Keep the ref in sync with the state
   countRef.current = count;

   const handleClickWithRef = () => {
       setCount(count + 1);
       // Using ref to get the latest value
       setTimeout(() => {
           console.log('Current count (Solution with Ref):', countRef.current);
           alert(`Current count (Solution with Ref): ${countRef.current}`);
       }, ۲۰۰۰);
   };

در این بازنویسی، به‌جای استفاده مستقیم از state

count
count، از مقدار
countRef
countRef استفاده می‌کنیم که به مقدار واقعی و به‌روز
count
count رفرنس دارد. این رفرنس در طول رندرهای مختلف حفظ می‌شود و بنابراین، مشکل ناشی از closure را برطرف می‌کند.

یک نمونه واقعی از Closure در جاوااسکریپت

در یکی از پروژه‌های واقعی در سطح تولید، تیمی که مسئول نگه‌داری و توسعه یک اپلیکیشن با به‌روزرسانی‌های real-time بود، با مشکلی در مدیریت state مواجه شد. اپلیکیشن داده‌های چند صف مختلف را مدیریت می‌کرد و هر صف را در یک تب مجزا در رابط کاربری نمایش می‌داد. پیام‌های مربوط به تغییرات این داده‌ها از طریق سرویس SignalR در Azure از سمت بک‌اند برای فرانت‌اند ارسال می‌شدند. این پیام‌ها مشخص می‌کردند که داده‌ها چگونه باید به‌روزرسانی شوند یا به صف دیگری منتقل گردند.

در جریان اجرای این فرآیند، تیم توسعه با خطاهای متعددی مواجه شد. برخی از به‌روزرسانی‌ها به‌درستی انجام می‌شد، اما سیستم برخی دیگر را نادیده می‌گرفت یا به‌صورت نادرست اعمال می‌کرد. این مسئله باعث نارضایتی کاربران شد و از آنجا که فرآیند ارتباط با SignalR به‌صورت real-time انجام می‌شود، عیب‌یابی و تست مشکل، نیاز به شبیه‌سازی پیام‌های سرور و تعامل مداوم داشت که فرآیندی پیچیده و زمان‌بر بود.

تمرکز بر کد فرانت‌اند: منشأ مشکل کجاست؟

تیم توسعه در ابتدا احتمال داد که مشکل از سمت سرور باشد. اما پس از بررسی کدهای بک‌اند و اطمینان از صحت عملکرد ارسال پیام‌ها، تمرکز تیم بر کدهای فرانت‌اند قرار گرفت. در بررسی دقیق‌تر مشخص شد که مسئله اصلی، به یک مشکل closure در جاوااسکریپت مربوط می‌شود. در کد فرانت‌اند، callback مربوط به دریافت پیام‌های SignalR در واقع به داده‌های قدیمی state دسترسی داشت که منجر به رفتارهای نادرست در رابط کاربری می‌شد.

بازنویسی با useRef: راه‌حلی برای حفظ مقدار به‌روز state

برای حل این مشکل، handler دریافت پیام‌ها بازنویسی شد و از هوک

useRef
useRef استفاده گردید تا همواره به جدیدترین مقدار state دسترسی داشته باشد. این دقیقاً همان روشی است که در مثال
setTimeout
setTimeout نیز پیش‌تر بررسی کردیم. در صورتی که مقاله را همراه با پروژه‌ی نمونه دنبال می‌کنید، این بخش در کامپوننت‌های
SignalRIssue
SignalRIssue و
SignalRSolution
SignalRSolution قابل بررسی است.

کد اولیه کامپوننت

SignalRIssue
SignalRIssue به‌صورت زیر می‌باشد:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
import React, { useState, useEffect } from 'react';
import { ValueLocation, MoveMessage } from '../types/message';
import { createMockHub, createInitialValues } from '../utils/mockHub';
import ValueList from './ValueList';
import MessageDisplay from './MessageDisplay';
const SignalRIssue: React.FC = () => {
const [tabAValues, setTabAValues] = useState<ValueLocation[]>(() =>
createInitialValues()
);
const [tabBValues, setTabBValues] = useState<ValueLocation[]>([]);
const [activeTab, setActiveTab] = useState<'A' | 'B'>('A');
const [lastMove, setLastMove] = useState<MoveMessage | null>(null);
useEffect(() => {
const hub = createMockHub();
hub.on('message', (data: MoveMessage) => {
// The closure captures these initial arrays and will always reference
// their initial values throughout the component's lifecycle
if (data.targetTab === 'A') {
// Remove from B (but using stale B state)
setTabBValues(tabBValues.filter((v) => v.value !== data.value));
// Add to A (but using stale A state)
setTabAValues([
...tabAValues,
{
tab: 'A',
value: data.value,
},
]);
} else {
// Remove from A (but using stale A state)
setTabAValues(tabAValues.filter((v) => v.value !== data.value));
// Add to B (but using stale B state)
setTabBValues([
...tabBValues,
{
tab: 'B',
value: data.value,
},
]);
}
setLastMove(data);
});
hub.start();
return () => {
hub.stop();
};
}, []); // Empty dependency array creates the closure issue
return (
<div className="p-4 bg-black rounded shadow">
<h2 className="text-xl font-bold mb-4">SignalR Issue</h2>
<div className="min-h-screen w-full flex items-center justify-center py-8">
<div className="max-w-2xl w-full mx-4">
<div className="bg-gray-800 rounded-lg shadow-xl overflow-hidden">
<MessageDisplay message={lastMove} />
<div className="border-b border-gray-700">
<div className="flex">
<button
onClick={() => setActiveTab('A')}
className={`px-6 py-3 text-sm font-medium flex-1 ${
activeTab === 'A'
? 'border-b-2 border-purple-500 text-purple-400 bg-purple-900/20'
: 'text-gray-400 hover:text-purple-300 hover:bg-purple-900/10'
}`}
>
Tab A ({tabAValues.length})
</button>
<button
onClick={() => setActiveTab('B')}
className={`px-6 py-3 text-sm font-medium flex-1 ${
activeTab === 'B'
? 'border-b-2 border-emerald-500 text-emerald-400 bg-emerald-900/20'
: 'text-gray-400 hover:text-emerald-300 hover:bg-emerald-900/10'
}`}
>
Tab B ({tabBValues.length})
</button>
</div>
</div>
{activeTab === 'A' ? (
<ValueList values={tabAValues} tab={activeTab} />
) : (
<ValueList values={tabBValues} tab={activeTab} />
)}
</div>
<div className="mt-4 p-4 bg-yellow-900 rounded-lg border border-yellow-700">
<h3 className="text-sm font-medium text-yellow-300">
Issue Explained
</h3>
<p className="mt-2 text-sm text-yellow-200">
This component demonstrates the closure issue where
the event handler captures the initial state values
and doesn't see updates. Watch as values may
duplicate or disappear due to stale state
references.
</p>
</div>
</div>
</div>
</div>
);
};
export default SignalRIssue;
import React, { useState, useEffect } from 'react'; import { ValueLocation, MoveMessage } from '../types/message'; import { createMockHub, createInitialValues } from '../utils/mockHub'; import ValueList from './ValueList'; import MessageDisplay from './MessageDisplay'; const SignalRIssue: React.FC = () => { const [tabAValues, setTabAValues] = useState<ValueLocation[]>(() => createInitialValues() ); const [tabBValues, setTabBValues] = useState<ValueLocation[]>([]); const [activeTab, setActiveTab] = useState<'A' | 'B'>('A'); const [lastMove, setLastMove] = useState<MoveMessage | null>(null); useEffect(() => { const hub = createMockHub(); hub.on('message', (data: MoveMessage) => { // The closure captures these initial arrays and will always reference // their initial values throughout the component's lifecycle if (data.targetTab === 'A') { // Remove from B (but using stale B state) setTabBValues(tabBValues.filter((v) => v.value !== data.value)); // Add to A (but using stale A state) setTabAValues([ ...tabAValues, { tab: 'A', value: data.value, }, ]); } else { // Remove from A (but using stale A state) setTabAValues(tabAValues.filter((v) => v.value !== data.value)); // Add to B (but using stale B state) setTabBValues([ ...tabBValues, { tab: 'B', value: data.value, }, ]); } setLastMove(data); }); hub.start(); return () => { hub.stop(); }; }, []); // Empty dependency array creates the closure issue return ( <div className="p-4 bg-black rounded shadow"> <h2 className="text-xl font-bold mb-4">SignalR Issue</h2> <div className="min-h-screen w-full flex items-center justify-center py-8"> <div className="max-w-2xl w-full mx-4"> <div className="bg-gray-800 rounded-lg shadow-xl overflow-hidden"> <MessageDisplay message={lastMove} /> <div className="border-b border-gray-700"> <div className="flex"> <button onClick={() => setActiveTab('A')} className={`px-6 py-3 text-sm font-medium flex-1 ${ activeTab === 'A' ? 'border-b-2 border-purple-500 text-purple-400 bg-purple-900/20' : 'text-gray-400 hover:text-purple-300 hover:bg-purple-900/10' }`} > Tab A ({tabAValues.length}) </button> <button onClick={() => setActiveTab('B')} className={`px-6 py-3 text-sm font-medium flex-1 ${ activeTab === 'B' ? 'border-b-2 border-emerald-500 text-emerald-400 bg-emerald-900/20' : 'text-gray-400 hover:text-emerald-300 hover:bg-emerald-900/10' }`} > Tab B ({tabBValues.length}) </button> </div> </div> {activeTab === 'A' ? ( <ValueList values={tabAValues} tab={activeTab} /> ) : ( <ValueList values={tabBValues} tab={activeTab} /> )} </div> <div className="mt-4 p-4 bg-yellow-900 rounded-lg border border-yellow-700"> <h3 className="text-sm font-medium text-yellow-300"> Issue Explained </h3> <p className="mt-2 text-sm text-yellow-200"> This component demonstrates the closure issue where the event handler captures the initial state values and doesn't see updates. Watch as values may duplicate or disappear due to stale state references. </p> </div> </div> </div> </div> ); }; export default SignalRIssue;
import React, { useState, useEffect } from 'react';
import { ValueLocation, MoveMessage } from '../types/message';
import { createMockHub, createInitialValues } from '../utils/mockHub';
import ValueList from './ValueList';
import MessageDisplay from './MessageDisplay';

const SignalRIssue: React.FC = () => {
    const [tabAValues, setTabAValues] = useState<ValueLocation[]>(() =>
        createInitialValues()
    );
    const [tabBValues, setTabBValues] = useState<ValueLocation[]>([]);
    const [activeTab, setActiveTab] = useState<'A' | 'B'>('A');
    const [lastMove, setLastMove] = useState<MoveMessage | null>(null);
    useEffect(() => {
        const hub = createMockHub();
        hub.on('message', (data: MoveMessage) => {
            // The closure captures these initial arrays and will always reference
            // their initial values throughout the component's lifecycle
            if (data.targetTab === 'A') {
                // Remove from B (but using stale B state)
                setTabBValues(tabBValues.filter((v) => v.value !== data.value));
                // Add to A (but using stale A state)
                setTabAValues([
                    ...tabAValues,
                    {
                        tab: 'A',
                        value: data.value,
                    },
                ]);
            } else {
                // Remove from A (but using stale A state)
                setTabAValues(tabAValues.filter((v) => v.value !== data.value));
                // Add to B (but using stale B state)
                setTabBValues([
                    ...tabBValues,
                    {
                        tab: 'B',
                        value: data.value,
                    },
                ]);
            }
            setLastMove(data);
        });
        hub.start();
        return () => {
            hub.stop();
        };
    }, []); // Empty dependency array creates the closure issue

    return (
        <div className="p-4 bg-black rounded shadow">
            <h2 className="text-xl font-bold mb-4">SignalR Issue</h2>
            <div className="min-h-screen w-full flex items-center justify-center py-8">
                <div className="max-w-2xl w-full mx-4">
                    <div className="bg-gray-800 rounded-lg shadow-xl overflow-hidden">
                        <MessageDisplay message={lastMove} />
                        <div className="border-b border-gray-700">
                            <div className="flex">
                                <button
                                    onClick={() => setActiveTab('A')}
                                    className={`px-6 py-3 text-sm font-medium flex-1 ${
                                        activeTab === 'A'
                                            ? 'border-b-2 border-purple-500 text-purple-400 bg-purple-900/20'
                                            : 'text-gray-400 hover:text-purple-300 hover:bg-purple-900/10'
                                    }`}
                                >
                                    Tab A ({tabAValues.length})
                                </button>
                                <button
                                    onClick={() => setActiveTab('B')}
                                    className={`px-6 py-3 text-sm font-medium flex-1 ${
                                        activeTab === 'B'
                                            ? 'border-b-2 border-emerald-500 text-emerald-400 bg-emerald-900/20'
                                            : 'text-gray-400 hover:text-emerald-300 hover:bg-emerald-900/10'
                                    }`}
                                >
                                    Tab B ({tabBValues.length})
                                </button>
                            </div>
                        </div>
                        {activeTab === 'A' ? (
                            <ValueList values={tabAValues} tab={activeTab} />
                        ) : (
                            <ValueList values={tabBValues} tab={activeTab} />
                        )}
                    </div>
                    <div className="mt-4 p-4 bg-yellow-900 rounded-lg border border-yellow-700">
                        <h3 className="text-sm font-medium text-yellow-300">
                            Issue Explained
                        </h3>
                        <p className="mt-2 text-sm text-yellow-200">
                            This component demonstrates the closure issue where
                            the event handler captures the initial state values
                            and doesn't see updates. Watch as values may
                            duplicate or disappear due to stale state
                            references.
                        </p>
                    </div>
                </div>
            </div>
        </div>
    );
};
export default SignalRIssue;

این کامپوننت به‌صورت اولیه بارگذاری می‌شود، به یک hub متصل می‌گردد (در اینجا یک نسخه‌ی شبیه‌سازی‌شده از اتصال SignalR ایجاد شده است)، و سپس به‌محض دریافت پیام‌ها، واکنش نشان می‌دهد. در کلاینت شبیه‌سازی شده SignalR از تابع

setInterval
setInterval استفاده شده و به‌صورت تصادفی مقادیر بین تب‌ها جابه‌جا می‌شوند:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
import { MoveMessage, ValueLocation } from '../types/message';
export const createInitialValues = (): ValueLocation[] => {
return Array.from({ length: 5 }, (_, index) => ({
value: index + 1,
tab: 'A',
}));
};
export const createMockHub = () => {
return {
on: (eventName: string, callback: (data: MoveMessage) => void) => {
// Simulate value movements every 2 seconds
const interval = setInterval(() => {
// Randomly select a value (1-5) and a target tab
const value = Math.floor(Math.random() * 5) + 1;
const targetTab = Math.random() > 0.5 ? 'A' : 'B';
callback({
type: 'move',
value,
targetTab,
timestamp: Date.now(),
});
}, ۲۰۰۰);
return () => clearInterval(interval);
},
start: () => Promise.resolve(),
stop: () => Promise.resolve(),
};
};
import { MoveMessage, ValueLocation } from '../types/message'; export const createInitialValues = (): ValueLocation[] => { return Array.from({ length: 5 }, (_, index) => ({ value: index + 1, tab: 'A', })); }; export const createMockHub = () => { return { on: (eventName: string, callback: (data: MoveMessage) => void) => { // Simulate value movements every 2 seconds const interval = setInterval(() => { // Randomly select a value (1-5) and a target tab const value = Math.floor(Math.random() * 5) + 1; const targetTab = Math.random() > 0.5 ? 'A' : 'B'; callback({ type: 'move', value, targetTab, timestamp: Date.now(), }); }, ۲۰۰۰); return () => clearInterval(interval); }, start: () => Promise.resolve(), stop: () => Promise.resolve(), }; };
import { MoveMessage, ValueLocation } from '../types/message';
export const createInitialValues = (): ValueLocation[] => {
    return Array.from({ length: 5 }, (_, index) => ({
        value: index + 1,
        tab: 'A',
    }));
};
export const createMockHub = () => {
    return {
        on: (eventName: string, callback: (data: MoveMessage) => void) => {
            // Simulate value movements every 2 seconds
            const interval = setInterval(() => {
                // Randomly select a value (1-5) and a target tab
                const value = Math.floor(Math.random() * 5) + 1;
                const targetTab = Math.random() > 0.5 ? 'A' : 'B';
                callback({
                    type: 'move',
                    value,
                    targetTab,
                    timestamp: Date.now(),
                });
            }, ۲۰۰۰);
            return () => clearInterval(interval);
        },
        start: () => Promise.resolve(),
        stop: () => Promise.resolve(),
    };
};

در صورت اجرای این کامپوننت، رفتارهای غیرعادی‌ای مشاهده می‌کنیم، از جمله این که در حالت ایده‌آل، باید فقط یک نمونه از مقدارهای

Value1
Value1 و
Value5
Value5 در لیست وجود داشته باشد؛ اما در این مثال چندین نمونه تکراری مشاهده می‌شود و به‌نظر می‌رسد که انتقال داده‌ها به تب B به‌درستی انجام نشده است.

با بررسی دقیق‌تر کد، می‌توان محل بروز مشکل closure را شناسایی کرد:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
hub.on('message', (data: MoveMessage) => {
// The closure captures these initial arrays and will always reference
// their initial values throughout the component's lifecycle
if (data.targetTab === 'A') {
// Remove from B (but using stale B state)
setTabBValues(tabBValues.filter((v) => v.value !== data.value));
// Add to A (but using stale A state)
setTabAValues([
...tabAValues,
{
tab: 'A',
value: data.value,
},
]);
} else {
// Remove from A (but using stale A state)
setTabAValues(tabAValues.filter((v) => v.value !== data.value));
// Add to B (but using stale B state)
setTabBValues([
...tabBValues,
{
tab: 'B',
value: data.value,
},
]);
}
hub.on('message', (data: MoveMessage) => { // The closure captures these initial arrays and will always reference // their initial values throughout the component's lifecycle if (data.targetTab === 'A') { // Remove from B (but using stale B state) setTabBValues(tabBValues.filter((v) => v.value !== data.value)); // Add to A (but using stale A state) setTabAValues([ ...tabAValues, { tab: 'A', value: data.value, }, ]); } else { // Remove from A (but using stale A state) setTabAValues(tabAValues.filter((v) => v.value !== data.value)); // Add to B (but using stale B state) setTabBValues([ ...tabBValues, { tab: 'B', value: data.value, }, ]); }
hub.on('message', (data: MoveMessage) => {
            // The closure captures these initial arrays and will always reference
            // their initial values throughout the component's lifecycle
            if (data.targetTab === 'A') {
                // Remove from B (but using stale B state)
                setTabBValues(tabBValues.filter((v) => v.value !== data.value));
                // Add to A (but using stale A state)
                setTabAValues([
                    ...tabAValues,
                    {
                        tab: 'A',
                        value: data.value,
                    },
                ]);
            } else {
                // Remove from A (but using stale A state)
                setTabAValues(tabAValues.filter((v) => v.value !== data.value));
                // Add to B (but using stale B state)
                setTabBValues([
                    ...tabBValues,
                    {
                        tab: 'B',
                        value: data.value,
                    },
                ]);
            }

در اینجا، handler پیام‌ها مستقیماً بر پایه یک state قدیمی عمل می‌کند. زمانی که پیام‌ها دریافت می‌شوند، handler بر مبنای نقطه‌ای از state کار می‌کند که در زمان اجرا قدیمی‌تر از مقدار واقعی و مورد انتظار است که باید در طول رندرهای متوالی حفظ شود.

برای حل این مشکل، می‌توانیم همان کاری را انجام دهیم که در مثال

setTimeout
setTimeout انجام دادیم، بازگشت به استفاده از هوک 
useRef
useRef:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
const [tabAValues, setTabAValues] = useState<ValueLocation[]>(() =>
createInitialValues()
);
const [tabBValues, setTabBValues] = useState<ValueLocation[]>([]);
const [activeTab, setActiveTab] = useState<'A' | 'B'>('A');
const [lastMove, setLastMove] = useState<MoveMessage | null>(null);
// Create refs to maintain latest state values
const tabAValuesRef = useRef(tabAValues);
const tabBValuesRef = useRef(tabBValues);
// Keep refs in sync with current state
tabAValuesRef.current = tabAValues;
tabBValuesRef.current = tabBValues;
const [tabAValues, setTabAValues] = useState<ValueLocation[]>(() => createInitialValues() ); const [tabBValues, setTabBValues] = useState<ValueLocation[]>([]); const [activeTab, setActiveTab] = useState<'A' | 'B'>('A'); const [lastMove, setLastMove] = useState<MoveMessage | null>(null); // Create refs to maintain latest state values const tabAValuesRef = useRef(tabAValues); const tabBValuesRef = useRef(tabBValues); // Keep refs in sync with current state tabAValuesRef.current = tabAValues; tabBValuesRef.current = tabBValues;
const [tabAValues, setTabAValues] = useState<ValueLocation[]>(() =>
       createInitialValues()
   );
   const [tabBValues, setTabBValues] = useState<ValueLocation[]>([]);
   const [activeTab, setActiveTab] = useState<'A' | 'B'>('A');
   const [lastMove, setLastMove] = useState<MoveMessage | null>(null);

   // Create refs to maintain latest state values
   const tabAValuesRef = useRef(tabAValues);
   const tabBValuesRef = useRef(tabBValues);

   // Keep refs in sync with current state
   tabAValuesRef.current = tabAValues;
   tabBValuesRef.current = tabBValues;

سپس در داخل handler مربوط به دریافت پیام‌ها، به‌جای استفاده از state، به مقدار جاری موجود در

.current
.current از ref ارجاع داده می‌شود:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
useEffect(() => {
const hub = createMockHub();
hub.on('message', (data: MoveMessage) => {
// Use refs to access current state values
const valueInA = tabAValuesRef.current.find(
(v) => v.value === data.value
);
if (data.targetTab === 'A') {
if (!valueInA) {
// Value should move to A
const valueInB = tabBValuesRef.current.find(
(v) => v.value === data.value
);
if (valueInB) {
// Use functional updates to ensure clean state transitions
setTabBValues((prev) =>
prev.filter((v) => v.value !== data.value)
);
setTabAValues((prev) => [
...prev,
{
tab: 'A',
value: data.value,
},
]);
}
}
} else {
if (valueInA) {
// Value should move to B
setTabAValues((prev) =>
prev.filter((v) => v.value !== data.value)
);
setTabBValues((prev) => [
...prev,
{
tab: 'B',
value: data.value,
},
]);
}
}
setLastMove(data);
});
hub.start();
return () => {
hub.stop();
};
}, []); // Empty dependency array is fine now because we're using refs
useEffect(() => { const hub = createMockHub(); hub.on('message', (data: MoveMessage) => { // Use refs to access current state values const valueInA = tabAValuesRef.current.find( (v) => v.value === data.value ); if (data.targetTab === 'A') { if (!valueInA) { // Value should move to A const valueInB = tabBValuesRef.current.find( (v) => v.value === data.value ); if (valueInB) { // Use functional updates to ensure clean state transitions setTabBValues((prev) => prev.filter((v) => v.value !== data.value) ); setTabAValues((prev) => [ ...prev, { tab: 'A', value: data.value, }, ]); } } } else { if (valueInA) { // Value should move to B setTabAValues((prev) => prev.filter((v) => v.value !== data.value) ); setTabBValues((prev) => [ ...prev, { tab: 'B', value: data.value, }, ]); } } setLastMove(data); }); hub.start(); return () => { hub.stop(); }; }, []); // Empty dependency array is fine now because we're using refs
useEffect(() => {
        const hub = createMockHub();
        hub.on('message', (data: MoveMessage) => {
            // Use refs to access current state values
            const valueInA = tabAValuesRef.current.find(
                (v) => v.value === data.value
            );
            if (data.targetTab === 'A') {
                if (!valueInA) {
                    // Value should move to A
                    const valueInB = tabBValuesRef.current.find(
                        (v) => v.value === data.value
                    );
                    if (valueInB) {
                        // Use functional updates to ensure clean state transitions
                        setTabBValues((prev) =>
                            prev.filter((v) => v.value !== data.value)
                        );
                        setTabAValues((prev) => [
                            ...prev,
                            {
                                tab: 'A',
                                value: data.value,
                            },
                        ]);
                    }
                }
            } else {
                if (valueInA) {
                    // Value should move to B
                    setTabAValues((prev) =>
                        prev.filter((v) => v.value !== data.value)
                    );
                    setTabBValues((prev) => [
                        ...prev,
                        {
                            tab: 'B',
                            value: data.value,
                        },
                    ]);
                }
            }
            setLastMove(data);
        });
        hub.start();
        return () => {
            hub.stop();
        };
    }, []); // Empty dependency array is fine now because we're using refs

همچنین متن مقاله به یک نکته مهم درباره Functional Updates اشاره می‌کند.

بررسی Functional Updates

در React، یک functional update به‌جای آن‌که مستقیماً state را تغییر دهد، مقدار قبلی state را به‌عنوان ورودی دریافت می‌کند و بر پایه‌ی آن مقدار جدید را محاسبه می‌کند. این رویکرد باعث می‌شود بتوانیم در life cycle یک کامپوننت، تغییرات را با دقت و بر اساس جدیدترین داده‌ها اعمال نماییم؛ حتی در شرایطی که رندرها متوالی یا async باشند. اگرچه استفاده از

useRef
useRef تا حد زیادی این مشکل را پوشش می‌دهد، اما توجه به این نکته در هنگام کار با closureها بسیار اهمیت دارد.

مسائل مربوط به closure معمولاً بسیار چالش‌برانگیز هستند؛ زیرا در ظاهر، عملکرد کد صحیح به‌نظر می‌رسد. در مواجهه با چنین مشکلاتی، باید مسیر تغییرات state را به‌صورت پیوسته در طول فرآیند دنبال کنیم. برای تشخیص دقیق مشکل closure، می‌توانیم از step debugging همراه با بررسی گام‌به‌گام تغییرات داده بهره‌مند شویم.

در مورد SignalR، این چالش پیچیده‌تر می‌شود؛ چرا که دریافت پیام‌ها در سمت کلاینت نیازمند وجود یک محرک مناسب در سمت سرور است. بنابراین بهتر است پیش از اعمال هرگونه راه‌حل مستقیم، فرآیند مربوطه را به‌دقت ردیابی کرده و رفتار state را در طول آن به‌صورت کامل تحلیل کنیم.

جمع‌بندی

در این مقاله آموختیم که چگونه:

همان‌طور که در طول مقاله نیز اشاره شد، کار با closureها در برخی مواقع می‌تواند چالش‌برانگیز باشد؛ به‌ویژه در محیط‌های production. بهترین رویکرد در مواجهه با این مسائل، داشتن درک دقیق از نحوه‌ی مدیریت state در اپلیکیشن است. در صورت بروز مشکل، توسعه‌دهنده باید مسیر جریان داده‌ها را مرحله‌به‌مرحله و به‌صورت پیوسته دنبال کرده و تحلیل کند.