Keeping Screens Visible With <Activity mode="hidden">

Authors
No items found.

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 to RCTView so it behaves like a normal View.
  • validAttributes: a map of attributes supported by the view. It’s a record of attribute names to either true (if the attribute is passed as-is) or an object with a process function 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.

Table of contents
Need help with React or React Native projects?

We support teams building scalable apps with React and React Native.

Let’s chat

//

//
React Native

We can help you move
it forward!

At Callstack, we work with companies big and small, pushing React Native everyday.

React Native Performance Optimization

Improve React Native apps speed and efficiency through targeted performance enhancements.

New Architecture Migration

Safely migrate to React Native’s New Architecture to unlock better performance, new capabilities, and future-proof releases.

Code Sharing

Implement effective code-sharing strategies across all platforms to accelerate shipping and reduce code duplication.

Mobile App Development

Launch on both Android and iOS with single codebase, keeping high-performance and platform-specific UX.

//
Insights

Learn more about React Native

Here's everything we published recently on this topic.