DEV Community

Cover image for How to manage JavaScript closures in React
Megan Lee for LogRocket

Posted on • Originally published at blog.logrocket.com

3 1

How to manage JavaScript closures in React

Written by Andrew Evans✏️

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.

What are JavaScript closures?

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:

  1. Global scope — Values are available everywhere
  2. Function scope — Values are only available within a specific function
  3. Block scope — Values are only available within a block of code (typically surrounded by braces: { 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";
}
Enter fullscreen mode Exit fullscreen mode

In the example code above:

  • globalValue  — Can be reached anywhere in the program
  • var1 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();
Enter fullscreen mode Exit fullscreen mode

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.

Closures in React

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](https://developer.mozilla.org/en-US/docs/Web/API/Window/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>
    );
};
Enter fullscreen mode Exit fullscreen mode

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);
    };
Enter fullscreen mode Exit fullscreen mode

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: count value captured by set timeout 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>
    );
};
Enter fullscreen mode Exit fullscreen mode

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);
    };
Enter fullscreen mode Exit fullscreen mode

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.

A real-world example of JavaScript closures: SignalR reference leaks in callbacks

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;
Enter fullscreen mode Exit fullscreen mode

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(),
    };
};
Enter fullscreen mode Exit fullscreen mode

If you ran my sample component, you would see odd behavior like this: odd behavior from sample component 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,
                    },
                ]);
            }
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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: values pass correctly between 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.

Conclusion

In this article, you learned how to:

  • Define closures both generally and in React JavaScript
  • Manage closures with concepts of scope
  • Work with closures in a simple example in JavaScript’s setTimeout function
  • Work with closures in a real-world example with connection callbacks in Azure’s SignalR service

As 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!


LogRocket: Debug JavaScript errors more easily by understanding the context

Debugging code is always a tedious task. But the more you understand your errors, the easier it is to fix them.

LogRocket allows you to understand these errors in new and unique ways. Our frontend monitoring solution tracks user engagement with your JavaScript frontends to give you the ability to see exactly what the user did that led to an error.

LogRocket Signup

LogRocket records console logs, page load times, stack traces, slow network requests/responses with headers + bodies, browser metadata, and custom logs. Understanding the impact of your JavaScript code will never be easier!

Try it for free.

Hostinger image

Get n8n VPS hosting 3x cheaper than a cloud solution

Get fast, easy, secure n8n VPS hosting from $4.99/mo at Hostinger. Automate any workflow using a pre-installed n8n application and no-code customization.

Start now

Top comments (0)

AWS Q Developer image

Your AI Code Assistant

Automate your code reviews. Catch bugs before your coworkers. Fix security issues in your code. Built to handle large projects, Amazon Q Developer works alongside you from idea to production code.

Get started free in your IDE

👋 Kindness is contagious

If you found this post helpful, please leave a ❤️ or a friendly comment below!

Okay