Capturing or passing a ref object to a worklet permanently breaks that ref object
See original GitHub issueDescription
In short: capturing a ref object in a worklet causes it to be frozen, meaning that it can no longer be updated. This feels like incorrect behaviour - a frozen copy should be passed to the worklet, but the original should be left unfrozen. The current behaviour causes some very tricky and hard-to-debug issues; for example if I add a console.log(myRef) inside a worklet that wasn’t capturing myRef before, I have actually changed (broken) the behaviour of my component, since myRef can no longer be updated. Adding a log shouldn’t be a breaking change!
In long: In some code I was working on, I was capturing a ref (MutableRefObject, obtained from react’s useRef) with a worklet (inside a handler passed into useAnimatedGestureHandler). I wanted the ref, rather than ref.current, because I wanted up-to-date contents of that ref at the moment the worklet is called - there might not have been a rerender since the ref value change; in other words I want to resolve .current in the worklet, not when the worklet was declared.
This gave me an unexpected result - after the worklet was first called, my ref became read-only even in code running on the React Native thread. Simply by capturing the ref in a worklet, I had frozen the ref for both threads, and could no longer update ref.current on either.
Here’s a toy component so you can see what I mean:
import { useRef, useState } from 'react';
import { Button, View } from 'react-native';
import { runOnUI } from 'react-native-reanimated';
function MyComponent() {
const [_, setFoo] = useState({});
const forceRerender = () => setFoo({});
const counterRef = useRef(0);
console.log('(RN thread) render', counterRef.current, Object.isFrozen(counterRef));
const worklet1 = () => {
'worklet';
const ref = counterRef;
console.log('(UI thread) worklet1', ref.current);
};
const worklet2 = () => {
'worklet';
console.log('(UI thread) worklet2', counterRef.current);
};
return <View>
<Button onPress={() => counterRef.current += 1} title={`Increment counter: ${counterRef.current}`}/>
<Button onPress={forceRerender} title='Force rerender'/>
<Button onPress={() => runOnUI(worklet1)()} title='Run worklet 1'/>
<Button onPress={() => runOnUI(worklet2)()} title='Run worklet 2'/>
</View>;
}
Here, we can increment a counter by clicking the top button, and force a rerender with the second button, so we can see the latest value. (I’m deliberately using useRef instead of useState for the sake of this example.)
worklet1 captures the entire counterRef.
- Good thing: if you increment the counter and then click “Run worklet 1”, you get the latest value of
counterRef! - Bad thing: after you click “Run worklet 1”, the entire thing completely breaks.
counterRefcan no longer be incremented. This is the phenomenon I’m describing. worklet1 is not simply given a copy of the ref object at the moment it is called, the original ref object is frozen (Object.isFrozen(counterRef) === true).
worklet2 captures just counterRef.current, evaluated at the time that worklet2 is declared. This doesn’t suffer from the bad thing above, but also doesn’t benefit from the good thing, making it unsuitable for what I was trying to do.
Note that on web, there’s no issue, since there’s no sending of objects between thread…
Steps to reproduce
This is a general issue - see the toy component above for a repro.
Snack or a link to a repository
https://snack.expo.dev/rpPA4YXB7
Reanimated version
2.9.1
React Native version
0.69.5
Platforms
Android, iOS
JavaScript runtime
No response
Workflow
Expo managed workflow
Architecture
No response
Build type
Debug mode
Device
Real device
Device model
Samsung Galaxy S20 FE
Acknowledgements
Yes
Issue Analytics
- State:
- Created a year ago
- Reactions:2
- Comments:6 (2 by maintainers)

Top Related StackOverflow Question
I had the same issue using refs inside a
Gesture.Tap().onStartcallback after addingreact-native-reanimated. Inside the callback I now do some stuff related to animations and some computations that userefobjects.I had to read the thread in details to find how to solve my issue, I thought adding a small example could help the next readers, so here it is.
Here’s my component before making it work:
And here’s how I fixed it, by basically putting my refs into a
SharedValueobject. Note that passing the return from auseRefas theSharedValuewon’t work.Hope this will help others 😃
Yep, I think this was me getting confused. Objects are captured when the function declared, not when it’s called. Since the function is on a different thread, the objects are copied to the new thread, so my ref.current (from inside the function) won’t change after the point where it’s captured. I understand now that I should have used useSharedValue.
I’m still not sure about 2 things:
Aside: My suggestion was to not freeze the object on the JS thread, and just send an immutable copy to the UI thread. Is it possible that the problem with that is more in “how to make a deep copy”, vs “this would be hard to debug”? Usually, making a deep copy is up to the owner of the object. Rust has a concept of a Clone trait that needs to be implemented on the struct itself, for example. No library can copy your struct if it doesn’t implement this. Rust also uses traits to specify whether something should be passed by copy instead of by move (Copy, requires Clone to be implemented) (JS doesn’t have the concept of move), and whether it is safe to move to other threads (Send), or even have a reference be shared by different threads (Sync). When capturing an object in a closure (function), that closure implements Copy, Send or Sync if all of its captured objects do. Is it possible that you do a freeze on the object, because there’s no way to reliably perform a deep copy (since there’s no way for objects to have the trait Clone/Copy implemented on them)? So, I propose that you make the following a standard pattern in the docs: manually copy your object and capture just the copy in the worklet. You could even add a marker to objects that have been manually copied in this way, and the react-native-reanimated errors if you try to capture an object which doesn’t have this marker.
Aside 2: Is it possible to have an ESLint plugin that spots where an object captured in a worklet is then mutated? I find that I only sometimes get the error “Attempting to define property on object that is not extensible.” or similar, and even if I do get that error it doesn’t give me accurate line numbers. Most of the time it silently just doesn’t mutate the object.