React Native’s New Architecture: The Tricky Parts (2/2)
The first part of our series ended with you implementing a custom Shadow Node. There is one thing to be wary of when it comes to Shadow Nodes — they may be cloned during each commit to build a new revision of the Shadow Tree, even multiple times in the same commit. Unfortunately, you do not have the guarantee that the most up-to-date node will be cloned as React may hold a reference to an obsolete one. This makes storing information inside them not as straightforward as just adding a field. You can still do that, but there is a high chance that it will not work as expected when an outdated node gets cloned. The solution to this is to store data in a state, which is propagated to the newly cloned nodes based on the latest one committed. This is unrelated to useState and is a completely independent mechanism from it.
Custom shadow node state
There are already many components using custom native state, here are some examples and their use cases to give you an idea of what it enables:
TextInput components store attributed strings in the state to avoid recreating them when not necessary
ScrollView stores the scroll offset, which is then used to correctly handle touches inside it
React Native Screens uses the state to correctly offset the content of a screen when a native header is present
Implementing a custom state, similar to the shadow node itself, is not that complicated. When you were implementing the custom shadow node, you needed to create a file with an empty state for it. You can update it to store whatever information you need — in this example, the state will store the dimensions of the status bar. Then those dimensions will be applied to the view. Let’s start with adding fields holding the width and height of the status bar to the state, getters for them, and initialize them in the constructors.
On iOS, you can create the state directly using constructors in Objective-C++. On Android, you need to rely on folly dynamic objects to pass data around between C++ and Java/Kotlin. When you have all the required data in the state, you can modify the component descriptor to update the shadow node based on the state. The adapt method will be called every time a new instance of the shadow node is created, no matter if it’s cloned or one from a completely new family. In this case, we will set the size of the component to match the status bar.
If you were to use the code as-is, you would notice that the component disappeared. That’s because the state is not updated anywhere and the size is set to zero pixels, which was set as a default value. You need a way to update the state from the platform-specific code — this is where state wrappers come into the picture. On iOS you can simply override the updateState method and store a pointer to the state wrapper inside the component. Then, you can use the wrapper to update the state when you gain access to relevant data or when it changes.
On Android the process is almost exactly the same — you can override the updateState method on the relevant view manager which will give you access to the state wrapper. You can store it inside the component, and when you have the information you need, you can use the wrapper to update the native state.
Commit hooks
Custom shadow nodes are a useful tool but they are limited by the fact that they can only affect the views that you have defined (and, to some extent, their subtree). In case you want to modify other views, you need to use commit hooks. Those are run during the commit phase before a new tree is submitted to be mounted and they give you access to the entire tree. Implementing them requires more work than custom shadow nodes — to modify the node in the tree, you need a reference to it beforehand. Most likely, the logic supposed to mark nodes to be modified will be in JavaScript to be easily accessible to users, which means that the reference to the node needs to be passed to the native side via JSI. Though, let’s not get ahead of ourselves and start with a barebones commit hook.
To actually run, the commit hook needs to be registered in the UI Manager. The simplest way to handle this is to register it from a Turbo Module where access to the UI Manager is easy. Let’s start with defining a method responsible for registering the commit hook, then call it in the global scope to ensure it’s ready before the first commit.
Next, add the logic responsible for registering the commit hook on iOS which is really straightforward — add a method that will give you access to the SurfacePresenter and create the hook instance.
Android part is more complicated as you need to use JNI to pass data between JVM and C++. In this example, fbjni will be used which makes the process much simpler. First, you should update the module to be a hybrid class — a class that has a part of its implementation in JVM and part in C++. Then implement the creation of the commit hook in the C++ part, which can be exposed to JVM where accessing the UI Manager is possible.
Now that the commit hook is registered, let’s add an actual implementation. In this example, it will change every registered node to be a square of 100 pixels.
The commit hook is ready, but it doesn’t do anything at this point. It is missing a way to register the nodes that need to be updated. To remedy that, create a separate helper class that will make it possible to share the logic between platforms. Inside the helper, add a function that will expose registering and unregistering views to the JS runtime using JSI.
To actually inject the functions into the JS runtime you need access to it. Thankfully, it’s easy to get it by updating the module to be a TurboModuleWithJSIBindings.
The logic is very similar on Android, where you need to add one more C++ method to the module.
At this moment, the only thing left to do is to register the component when it’s mounted and unregister it when it’s unmounted.
Keep in mind that the view is registered after it has been mounted so you might see it flicker for one frame. To avoid this, you can modify the custom shadow node to automatically register itself with the hook when it is created, assuming the commit hook is supposed to work only with those. Unfortunately, there is not much to do when it comes to external nodes.
Thanks for reading! :)