One of my plans for React Navigation 8 is to use <React.Activity> to unmount effects on inactive screens by setting mode="hidden". It will improve performance and memory usage by removing subscriptions and updates on screens that aren’t active.
It is essentially what react-native-screens does today with its experimental react-freeze integration, but better integrated with React Navigation as we have more control over when the screens are frozen.
However, <Activity mode="hidden"> hides the content, which is not ideal for our use case. Especially on a stack navigator where you can swipe back to the previous screen with gestures. If we don’t manage to make the previous screen visible in time, the user will see a flash of blank screen when they start swiping back. This is especially tricky in React Native as the native event isn't delivered synchronously, and we don’t have high-priority state updates like ReactDOM.flushSync to toggle visibility immediately.
So I experimented with making screens with <Activity mode="hidden"> stay visible, while still unmounting effects. I’ll share the implementation details here in case it can be useful for others.
What <Activity mode="hidden"> does
When you set mode="hidden" on an <Activity>, it does two things:
- Unmounts effects for all React components inside the activity
- Hides the content from the screen with
display: none
This means cleanup functions in useEffect or useLayoutEffect will run, so subscriptions get removed, timers get cleared, etc. which avoids unnecessary updates for hidden content.
In other words, the content is still there, but it’s (almost) frozen and invisible.
Going rogue: Keeping content visible
What we want is the frozen part, but not the invisible part. We could do that by either preventing display: none from being applied, or by undoing it immediately after it’s applied. Both approaches were necessary depending on the platform.
Native platforms
On native platforms like Android and iOS, we use RCTView (the name of the native view class for View) and pass a custom configuration to it.
This custom view configuration supports the following properties relevant to our use case:
uiViewClassName: the name of the native view class to use. We set it toRCTViewso it behaves like a normalView.validAttributes: a map of attributes supported by the view. It’s a record of attribute names to eithertrue(if the attribute is passed as-is) or an object with aprocessfunction that transforms the value before it’s applied to the native view.
The component already supports all attributes of a normal View by default. What we pass in our view configuration is a partial override which will be merged with the default configuration.
In our case, we need to override the style attribute, so we can override how display style is handled with the process function. We always return contents for any value, even for none. This way, when Activity sets display: none, it will be overridden to display: contents and the content will stay visible.
The implementation looks like this:
import { NativeComponentRegistry } from "react-native";
const viewConfig = {
uiViewClassName: "RCTView",
validAttributes: {
style: {
process: (value) => "contents",
},
},
};
export const MyCustomView: HostComponent = NativeComponentRegistry.get(
"MyCustomView",
() => viewConfig,
);
In this case, MyCustomView is a view that only supports the display style, and it will always override it to contents. We use contents so that the view itself doesn’t affect layout and the content is directly visible.
Then we can use this view inside the Activity:
<Activity mode={active ? "visible" : "hidden"}>
<MyCustomView style={{ display: "contents" }}>
{children}
</MyCustomView>
</Activity>Now mode="hidden" can unmount effects for inactive content, but when display: none is applied, it will be overridden to display: contents and the content will stay visible.
I got the idea for this approach from react-native-screens which got it from Navigation router.
It overrides the validAttributes in a ref callback by accessing the internal __viewConfig property of the view instance, which is a bit hacky and applies to all instances of the view, not the only one where it’s called, which may be a bit unexpected.
Web
On the web, we can’t define our own div which overrides how display is applied. So we undo the display: none style immediately instead.
To do this, first, we get the node by using a ref callback, and then use MutationObserver to track changes to the style attribute.
The one 'gotcha' here is that ref for the div under Activity will be cleaned up when mode="hidden" is set, so we add another wrapper div around Activity and attach the ref to it, then observe its children for style changes.
The implementation looks like this:
const onRef = useCallback((node) => {
const observers = node.childNodes.forEach((child) => {
const observer = new MutationObserver(() => {
child.style.display = "contents";
});
observer.observe(child, {
attributes: true,
attributeFilter: ["style"],
});
return observer;
});
return () => {
observers.forEach((observer) => observer.disconnect());
};
}, []);
return (
<div ref={onRef} style={{ display: "contents" }}>
<Activity mode={active ? "visible" : "hidden"}>
<div style={{ display: "contents" }}>{children}</div>
</Activity>
</div>
);
In the future, it will be possible to use a <Fragment> instead of the wrapper div and pass the ref to it. At the time of writing, it’s only supported in React’s canary versions, so I didn’t use it.
This approach is inspired by the article “A React trick to improve exit animations”. It suspends the component instead of using <Activity> (same as what react-freeze does), but the way it keeps the content visible is the same.
Conclusion
Overriding the display style this way feels a bit hacky, and needs more testing to ensure it works correctly across all scenarios, but it greatly improves the user experience with stack-based navigation that offers animations and gestures. I hope React will provide a built-in way to keep content visible in the future, but for now, this is a decent workaround that works across platforms.

Learn more about React Native
Here's everything we published recently on this topic.






















