---
title: "Keeping Screens Visible With <Activity mode=\"hidden\">"
description: "A cross‑platform workaround to keep screens visible while <Activity mode=\"hidden\"> unmounts effects on inactive screens by setting mode=\"hidden\"."
url: https://www.callstack.com/blog/keeping-screens-visible-with-activity-mode-hidden
updated: 2026-10-24T08:39:32.381Z
---

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

A cross‑platform workaround to keep screens visible while <Activity mode="hidden"> unmounts effects on inactive screens by setting mode="hidden".

_By Satyajit Sahoo, Software Engineer @ Callstack · Published 2026-03-05 · Category: React Native_

One of my plans for React Navigation 8 is to use [`<React.Activity>`](https://react.dev/reference/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`](https://github.com/software-mansion/react-native-screens) does today with its experimental [`react-freeze`](https://github.com/software-mansion/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](https://reactnavigation.org/docs/native-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](https://react.dev/reference/react-dom/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`](https://react.dev/reference/react/useEffect) or [`useLayoutEffect`](https://react.dev/reference/react/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:

```typescript
import { NativeComponentRegistry } from "react-native";

const viewConfig = {
  uiViewClassName: "RCTView",
  validAttributes: {
    style: {
      display: {
        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`:

```typescript
<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.

[Video](https://assets.callstack.com/activity-android-optimised.webm)

[Video](https://assets.callstack.com/activoty-ios%20(1).webm)

I got the idea for this approach from [`react-native-screens`](https://github.com/software-mansion/react-native-screens/blob/33eb2b58e741855ac87e0e4f29d59761bbd3f635/src/components/Screen.tsx#L186-L206) which got it from [Navigation router](https://github.com/grahammendick/navigation/pull/860).

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`](https://react.dev/reference/react/useRef) callback, and then use [`MutationObserver`](https://developer.mozilla.org/en-US/docs/Web/API/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:

```typescript
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\>](https://react.dev/reference/react/Fragment) instead of the wrapper div and [pass the ref to it](https://react.dev/reference/react/Fragment#using-fragment-refs-for-dom-interaction). At the time of writing, it’s only supported in React’s canary versions, so I didn’t use it.

[Video](https://assets.callstack.com/activity-android-optimised.webm)

This approach is inspired by the article “[A React trick to improve exit animations](https://barvian.me/react-exit-animations)”. It suspends the component instead of using `<Activity>` (same as what [`react-freeze`](https://github.com/software-mansion/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.
