The proper handling of JavaScript closures is essential to any JavaScript project.
In React projects specifically, closures can manifest themselves in ways that are not always readily apparent.
In this article, I will explain what closures are and provide examples of how to manage them. We’ll also cover a real-life example that I handled with my professional job and the production application we support.
I’ll be referencing my sample project on GitHub throughout the article.
A JavaScript closure is the relationship between a JavaScript function and references to its surrounding state. In JavaScript, state values have “scope” — which defines how accessible a value is. The more general concept of reference access is also called “lexical scope.” There are three main levels of scope in JavaScript:
{
and }
Here is an example of scope in code:
// 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"; }
In the example code above:
globalValue
— Can be reached anywhere in the programvar1
and var2
— Can only be reached inside yourFunction
b1
and b2
— Can only be accessed when globalValue
= “available anywhere”Closures happen when you make variables available inside or outside of their normal scope. This can be seen in the following example:
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();
In JavaScript projects, closures can cause issues where some values are accessible and others are not. When working with React specifically, this often happens when handling events or local state within components.
If you’d like a more in-depth review of closures in general, I recommend checking out our article on JavaScript closures, higher-order functions, and currying.
React projects usually encounter closure issues with managing state. In React applications, you can manage state local to a component with useState
. You can also leverage tools for centralized state management like Redux, or React Context for state management that goes across multiple components in a project.
Controlling the state of a component or multiple components requires the understanding of what values are accessible and where. When managing state in a React project, you may encounter frustrating closure issues where inconsistent changes can occur.
To better explain the concepts of closures in React, I’ll show an example using the built-in setTimeout
function. After that example in the following section, I will cover a real world production issue I had to resolve with closures. In all of these examples, you can follow along with my sample project.
Consider an application that takes in an input and does an async action. Usually you would see this with a form, or something that would take in client inputs and then pass them over to an API to do something. We can simplify this with a setTimeout
in a component like the following:
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}`); }, 2000); }; 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> ); };
This looks like something that should not have issues. The user clicks a button and a counter value is incremented and then shown in an alert modal. Where the issue happens is:
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}`); }, 2000); };
The count
value is captured by the setTimeout
function call in a closure. If you took this example and attempted to click the button multiple times in rapid succession you would see something like this:
In that screenshot, the Current Count: 1 indicates that the count
value is actually “1.” Since the setTimeout
created a closure and locked the value to the initial 0, the modal shows 0.
To resolve this issue, we can use the useRef
Hook to create a reference that always has the latest value across re-renders. With React state management, issues can occur where a re-render pulls data from a previous state.
If you just use useState
Hooks without a lot of complexity, you generally can get away with the standard getting and setting state. However, closures in particular data can have issues persisting as updates occur. Consider a refactor of our original component like the following:
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}`); }, 2000); }; 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> ); };
The difference in the code from the original issue is:
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}`); }, 2000); };
You’ll notice that we are using the countRef
value, which references the actual state value for count
. The reference persists across re-renders and thus resolves this closure issue. If you’d like more information on useRef, I recommend reviewing the LogRocket’s guide to React Refs.
In my professional role, I am a tech lead of a product team that manages an application used nationally by my company. This application handles real-time updates of data that reside in different queues. These queues are shown visually on a page with multiple tabs (one tab per queue). The page will receive messages from Azure’s SignalR service when the data is changed by backend processes. The messages received indicate how to either update the data or move it to a different queue.
My team encountered an issue where this whole process was generating multiple errors. Basically, some updates seemed to be occurring correctly, while others were missed or incorrect. This was very frustrating for our users. It was also very difficult to debug as the SignalR service operates in real time, and requires triggering messages to be sent from the server to the client.
Initially, I thought that this had to be something on our backend. I walked through the backend processes that generate the SignalR messages with the devs on my team. When it became apparent that the messages were being sent correctly, I switched over to looking at the frontend project.
In a deep dive of the code, I found that the issue was basically a closure problem. We were using the SignalR client package from Microsoft, and the event handler that was receiving the messages was incorrectly acting on old state.
For the solution to my problem, I refactored the message handler and also used the useRef
hook that I had mentioned before. If you’re following along on my sample project, I’m referring to the SignalRIssue
and SignalRSolution
components.
Consider the original SignalRIssue component:
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;
The component basically loads, connects to a hub (here I’ve created a mock version of the SignalR connection) and then acts when messages are received. In my mocked SignalR client, I have it using setInterval
and randomly moving values from one tab to another:
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(), }); }, 2000); return () => clearInterval(interval); }, start: () => Promise.resolve(), stop: () => Promise.resolve(), }; };
If you ran my sample component, you would see odd behavior like this:
There should only be one occurrence of Value1
and Value5
in that list. Instead, there are multiple, and it looks like nothing is being moved over to Tab B.
Looking at the code, you can see the closure issue here:
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, }, ]); }
The message handler is operating directly on the stale state when updating values. When the handler receives the messages, it’s operating on a point in the state change that is older vs. the actual value that should persist across re-renders.
To resolve this situation, you can do what I did in the setTimeout
example and go back to the useRef
Hook:
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;
Then in the message handler, you look for values from the reference vs. a stale read of the components state by looking at the .current
values:
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
If you notice, I also made a comment about “functional updates.”
In React, a “functional update” takes in the state’s previous value and acts on that instead of directly modifying the state. This ensures that you can basically do an update in the components lifecycle on the latest value vs. attempting to act on something that may be missed in a re-render. The useRef
usage should cover this, but this is an important additional point when dealing with closures.
With the resolved code written, you should now see something like this where the values correctly pass back and forth between the tabs:
When I worked on a resolution to the production issue I mentioned, I went through a fairly exhaustive set of steps debugging the backend processes first and working my way up to the frontend.
Closure issues can often be frustrating, because on the surface it appears that the updates are handled correctly. The biggest takeaway I had with this issue was to incrementally follow the state as it is passed through a process. To correctly figure out my team’s closure issue, I did both step debugging and walked through the data change at each step.
With SignalR, this can be difficult because you need something to trigger the update to receive it on the client side. Ultimately, I recommend tracing through a process before jumping straight into a solution when you see issues like this.
In this article, you learned how to:
setTimeout
functionAs I mentioned throughout the article, closures can be frustrating at times (especially when dealing with production). The best thing I have found is to understand how your application is managing state, and then trace processes on that state when seeing issues.
I hope this article has helped you to understand closures, and how you can work with them in React specifically. Thanks for reading my post!
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBy building these four simple projects, you’ll learn how CSS variables can help you write reusable, elegant code and streamline the way you build websites.
Explore AI’s impact in software development, its limitations, and how developers can stay competitive in the AI-driven industry.
Looking for the best React Native chart library? Explore the top 10 options, compare performance, and find the right tool for your project.
Compare the Bash vs. Zsh shell command languages, explore their differences, and see how to use both successfully.