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 ref
s. 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?
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.