An in-depth look at closures in React

An in-depth look at closures in React

ยท

6 min read

What will you do when a function defined inside a component stops referencing the latest value of the component's state? The answer: you unlearn.

This is the same scenario my friend Bhaumik faced while prototyping his React Native app the other day. Have a look at this component:

function ProfileScreen({ navigation }) {
    const { editUser } = useAuth();
    const [name, setName] = useState('');

    function submitUserData() {
        editUser(name);
    }

    useLayoutEffect(() => {
        navigation.setOptions({
            headerRight: () => (
                <TouchableOpacity onPress={submitUserData}>
                    <Text>Done</Text>
                </TouchableOpacity>
            ),
        });
    }, [navigation]);

    return (
        <View>
            <TextInput
                value={name}
                onChangeText={setName}
                autoFocus
            />
        </View>
    );
}

From a glance, we see that the name state is being used inside submitUserData component and headerRight component's onPress is invoking this handler function. So after the user types their name in the text input and presses the "Done" button, edituser function should receive the latest value of name, right? Since submitUserData is defined inside ProfileScreen, this function should always have access to the latest value of name state. Right? Nope!

Let's dig deep

Every time the user pressed the "Done" button, editUser function was receiving the initial value of name. This meant that headerLeft component seemed to have captured the snapshot of the submitUserData function in time when the component was created i.e., when useLayoutEffect ran. No matter how many times the name is updated, whenever "Done" is pressed the initial value of name was logged, not the latest value. There seems to be a mismatch between how ProfileScreen is working and how headerRight component is working. Clearly, submitUserData as access to ProfileScreen's state variables because it closes over them. So why when this function called from onPress of headerRight it doesn't have the latest value of the state of ProfileScreen?

So if state variables are not working inside submitUserData then what could work in this specific use case?

useRef to the rescue!

After numerous trials and errors, we discovered that if we store the value of the user's input in ref and use that inside submitUserData then it seems to be working...wait what? But how?

function ProfileScreen({ navigation }) {
    const { editUser } = useAuth();
    const [name, setName] = useState('');
    const ref = useRef('');

    function submitUserData() {
        editUser(ref.current); // -> has access to the latest value
    }

    useLayoutEffect(() => {
        navigation.setOptions({
            headerRight: () => (
                <TouchableOpacity onPress={submitUserData}>
                    <Text>Done</Text>
                </TouchableOpacity>
            ),
        });
    }, [navigation]);

    return (
        <View>
            <TextInput
                value={name}
                onChangeText={(text) => {
                    ref.current += text;
                    setName(text);
                }}
                autoFocus
            />
        </View>
    );
}

ref sort of exists outside the component. Component's inner workings don't inherently mutate refs. Also, since ref are mutable, its most updated value is - always available, at any point, immediately. There is no async rerendering involved. And because of this when submitUserData was snapshot on creation of headerRight it remembered ref because cloures. When ref.current mutated, that snapshot submitUserData still has access to the latest value.

If you think about it, this ๐Ÿ‘‡ would have worked too!

let userInput = ''; // -> non-React variable declared above React component
function ProfileScreen({ navigation }) {
    const { editUser } = useAuth();
    const [name, setName] = useState('');
    // const ref = useRef('');

    function submitUserData() {
        editUser(userInput); // -> has access to the latest value
    }

    useLayoutEffect(() => {
        navigation.setOptions({
            headerRight: () => (
                <TouchableOpacity onPress={submitUserData}>
                    <Text>Done</Text>
                </TouchableOpacity>
            ),
        });
    }, [navigation]);

    return (
        <View>
            <TextInput
                value={name}
                onChangeText={(text) => {
                    // ref.current += text;
                    userInput += text;
                    setName(text);
                }}
                autoFocus
            />
        </View>
    );
}

Even this ๐Ÿ‘‡!

function ProfileScreen({ navigation }) {
    const { editUser } = useAuth();
    const [name, setName] = useState('');

    function submitUserData() {
        editUser(name);
    }

    useLayoutEffect(() => {
        navigation.setOptions({
            headerRight: () => (
                <TouchableOpacity onPress={submitUserData}>
                    <Text>Done</Text>
                </TouchableOpacity>
            ),
        });
    }, [navigation, name]); // -> `name` is now inlcuded in dep array

    return (
        <View>
            <TextInput
                value={name}
                onChangeText={setName}
                autoFocus
            />
        </View>
    );
}

However, this approach has a major performance issue. On every keystroke, useLayoutEffect runs which means navigation.setOptions() runs which in turn destroys and remounts headerRight component. In the example, the component is not that complex but you can imagine how quickly this might become an issue.

Why state didn't work?

Because state is immutable. React handles mutating state, not JavaScript. Even though that first snapshot of submitUserData remembers the state of ProfileScreen, whenever submitUserData is called it will ask React to get the value of the state and React will always give it the initial value. Why? Because React keeps track of all component states and when and how they should be updated. One thing to note is that submitUserData is recreated in memory on every state update and it is available to ProfileScreen's other methods and child components. But the latest snapshot of submitUserData is not available to headerRight because it wasn't notified of ProfileScreen state's change so it still holds onto the old snapshot of submitUserData. This is why when we added name in useLayoutEffect's dependency array, everything worked as expected (because it was "notified" of the change).

Unusual nature of React's setState

What do you think would happen here?

function ProfileScreen({ navigation }) {
    const { editUser } = useAuth();
    const [name, setName] = useState('');
    const [isSaving, setSaving] = useState(false); // -> new state tracks "Done" btn press

    function submitUserData() {
        setSaving(true); // -> state change / rerender triggered
    }

    useEffect(() => {
        if (isSaving) {
            editUser(name);
            setIsSaving(false);
            navigation.goBack();
        }
    }, [isSaving]); // -> useEffect running every time isSaving changes

    useLayoutEffect(() => {
        navigation.setOptions({
            headerRight: () => (
                <TouchableOpacity onPress={submitUserData}>
                    <Text>Done</Text>
                </TouchableOpacity>
            ),
        });
    }, [navigation]);

    return (
        <View>
            <TextInput
                value={name}
                onChangeText={setName}
                autoFocus
            />
        </View>
    );
}

It works as expected - setSaving properly updates state, rerenders ProfileScreen and triggers useEffect to run which results in editUser having the latest value of name.

Wait what?

We just saw that headerRight component only took the first snapshot of submitUserData and whatever is inside it is frozen in time. But now that we have a setState call inside, it magically works?

Any sufficiently advanced technology is indistinguishable from magic. - Arthur C. Clarke

setState functions are special in React. Its job is to tell React that a request to change state is made. When a setState is spawned on component creation, it lives until the component is unmounted. Its identity in the memory doesn't change under any circumstances, unlike normal functions. So no matter where and when you invoke it, the execution is exactly as you expect. When setSaving was called from submitUserData, it invoked the same function that was created when ProfileScreen mounted. Multiple rerendering of ProfileScreen (and multiple re-definitions of submitUserData) did not have any effect on the setState call. It doesn't matter when setState is being invoked - at the first snapshot or the last because there is only one setState exists in the memory.

Conclusion

JavaScript's closures are complex enough but React's closures are completely something else. This little adventure through React's bowels gave us an in-depth insight into how states work in React. Hope you learned something today.

ย