Skip to main content

Conquering Modals in React Native

Cross-platform modals are a powerful yet tricky tool.

Let's cover a few things:

  • Use cases: when should I use a modal?
  • Quirks: how does the behavior differ across platforms?
  • Implementation: what are my options?

The options​

React Native has a Modal component:

tsx
import { Modal, Platform } from 'react-native'
export const MyModal = ({ children }) => (
<Modal
visible
onRequestClose={close}
presentation="formSheet"
animationType="slide"
transparent={Platform.OS != 'ios'}
>
{children}
</Modal>
)
tsx
import { Modal, Platform } from 'react-native'
export const MyModal = ({ children }) => (
<Modal
visible
onRequestClose={close}
presentation="formSheet"
animationType="slide"
transparent={Platform.OS != 'ios'}
>
{children}
</Modal>
)

On iOS & Android, you can also render a page as a modal inside of a stack.

tsx
import { createNativeStackNavigator } from '@react-navigation/native-stack'
const { Navigator, Screen } = createNativeStackNavigator()
export default function App() {
return (
<Navigator>
<Screen
name="Home"
component={Home}
screenOptions={{
presentation: 'modal',
}}
/>
</Navigator>
)
}
tsx
import { createNativeStackNavigator } from '@react-navigation/native-stack'
const { Navigator, Screen } = createNativeStackNavigator()
export default function App() {
return (
<Navigator>
<Screen
name="Home"
component={Home}
screenOptions={{
presentation: 'modal',
}}
/>
</Navigator>
)
}

When to use each one​

The Modal element is best for ephemeral UI where URLs aren't necessary. One exception to that rule is Next.js route modals, which we'll cover at the end.

For authentication, I prefer to render a global auth modal rather than a /login page to ensure that users don't throw away their state when trying to log in on Web.

iOS modals are weird​

If you've used React Native, you've probably struggled with modals on iOS.

Here are some of the limitations, along with the workarounds solutions.

Mutliple modals​

React Native only lets you mount a single <Modal /> at a time on iOS.

Imagine you have an <AuthModal /> in App.tsx at the root of your app, controlled by a global state variable. What happens if you try to open it while another modal is already open? Nothing. It won't open. I've encountered this many times with great frustration.

Coming from Web, one might expect that you need to the add fixed position and increase your z-index. But that doesn't apply here.

The solution​

tsx
// this won't work 😢
const [isAuthModalOpen, setIsAuthModalOpen] = useState(false)
const openAuthModal = () => setIsAuthModalOpen(true)
return (
<>
<Modal visible>
<Button onPress={viewAuthModal} title="Sign In" />
</Modal>
<Modal visible={isAuthModalOpen}>
<AuthFlow />
</Modal>
</>
)
tsx
// this won't work 😢
const [isAuthModalOpen, setIsAuthModalOpen] = useState(false)
const openAuthModal = () => setIsAuthModalOpen(true)
return (
<>
<Modal visible>
<Button onPress={viewAuthModal} title="Sign In" />
</Modal>
<Modal visible={isAuthModalOpen}>
<AuthFlow />
</Modal>
</>
)

To fix this, render the second modal inside of the first one:

tsx
// this will work 😎
const [isAuthModalOpen, setIsAuthModalOpen] = useState(false)
const openAuthModal = () => setIsAuthModalOpen(true)
return (
<Modal visible>
<Button onPress={viewAuthModal} title="Sign In" />
<Modal visible={isAuthModalOpen}>
<AuthFlow />
</Modal>
</Modal>
)
tsx
// this will work 😎
const [isAuthModalOpen, setIsAuthModalOpen] = useState(false)
const openAuthModal = () => setIsAuthModalOpen(true)
return (
<Modal visible>
<Button onPress={viewAuthModal} title="Sign In" />
<Modal visible={isAuthModalOpen}>
<AuthFlow />
</Modal>
</Modal>
)

The same applies for a navigation stack modal. If you try to open a globally-rendered modal while a navigation stack modal is open, your app just...freezes. The solution is to mount the modal inside of the navigation stack modal. I pulled my hair out with this problem yesterday.

With this limitation in mind, let's use React Context to override the auth modal's visibility state.

For instance, if your root app file looks like this:

tsx
// App.tsx
<AuthModalProvider>
<Navigator>
<Screen
name="Home"
component={Home}
screenOptions={{
presentation: 'modal',
}}
/>
</Navigator>
<AuthModal />
</AuthModalProvider>
tsx
// App.tsx
<AuthModalProvider>
<Navigator>
<Screen
name="Home"
component={Home}
screenOptions={{
presentation: 'modal',
}}
/>
</Navigator>
<AuthModal />
</AuthModalProvider>

In Home component, add AuthModalProvider and AuthModal once again:

tsx
export function Home() {
return (
<AuthModalProvider>
{/* your screen content goes here... */}
<AuthModal />
</AuthModalProvider>
)
}
tsx
export function Home() {
return (
<AuthModalProvider>
{/* your screen content goes here... */}
<AuthModal />
</AuthModalProvider>
)
}

This pattern does two things:

  1. Re-mount the AuthModal element inside of the Home stack page modal.
  2. Use React Context to override the AuthModalProvider so that all children of Home will open the modal mounted inside of the page.

Toasts​

React Native doesn't have position: 'fixed'. That said, if you use position: 'absolute' for an element at the root of your app, it will render on top of every other element. Except for modals.

Modals render on top of everything. This can be frustrating when you want to mount a component like a Toast at the root of your app and have it show on top of any arbitrary element. On Web, you can use a portal, or simply set fixed position with a really high z-index.

This limitation is why I built Burnt, a library for Toasts. Rather than using React Native UI, Burnt wraps an existing Swift library called SPIndicator. As a result, the toast UI is native, and it renders on top of any modal.

Next.js route modals​

A common pattern on Next.js is to use shallow routing to open a page as a modal. This lets you preserve local state and scroll position when navigating to a new page.

For example, if you're on the /search screen and you click an artist, rather than opening the actual artist page, you can render a modal on top of the search list that displays the artist page component. The URL changes, but the search list doesn't unmount.

tsx
// pages/search
import { createParam } from 'solito'
import { Modal } from 'react-native'
import dynamic from 'next/dynamic'
const ArtistPage = dynamic(() => import('../artist/[artistSlug]'))
import { SearchList } from 'app/features/search/list'
const { useParam } = createParam<{
artistSlug?: string
}>()
export default function SearchPage() {
const [artistSlug] = useParam('artistSlug')
return (
<>
<SearchList />
{/** render the artist page as a modal */}
<Modal visible={Boolean(artistSlug)}>
<ArtistPage />
</Modal>
</>
)
}
tsx
// pages/search
import { createParam } from 'solito'
import { Modal } from 'react-native'
import dynamic from 'next/dynamic'
const ArtistPage = dynamic(() => import('../artist/[artistSlug]'))
import { SearchList } from 'app/features/search/list'
const { useParam } = createParam<{
artistSlug?: string
}>()
export default function SearchPage() {
const [artistSlug] = useParam('artistSlug')
return (
<>
<SearchList />
{/** render the artist page as a modal */}
<Modal visible={Boolean(artistSlug)}>
<ArtistPage />
</Modal>
</>
)
}

While code above is only used in your Next.js app, it imports our SearchList component which is shared across platforms:

tsx
// features/search/list
export function SearchList() {
const router = useRouter()
return (
<ArtistResults
onPressArtist={(artist) => {
// open the current page with an artistSlug search parameter
// the URL shows /@{artistSlug}
router.push(`/search?artistSlug=${artist.slug}`, `/@${artist.slug}`, {
shallow: true,
})
}}
/>
)
}
tsx
// features/search/list
export function SearchList() {
const router = useRouter()
return (
<ArtistResults
onPressArtist={(artist) => {
// open the current page with an artistSlug search parameter
// the URL shows /@{artistSlug}
router.push(`/search?artistSlug=${artist.slug}`, `/@${artist.slug}`, {
shallow: true,
})
}}
/>
)
}

Here we opened /search?artistSlug=the-beatles and the URL visible to the user changed to /@the-beatles. The search page didn't unmount, and the artist page rendered on top of it inside of a modal. If the user refreshes, they'll open the actual page at /@the-beatles. If they click the back button, they'll go back to /search with preserved state.

What would the behavior be on native? Solito prefers the as path you set when calling router.push on native (see the second argument passed above). On iOS and Android, it would open the screen at /@the-beatles in a stack.

If you find yourself using route modals often, you can create a component wrapper that handles the logic for you. Here's an example from Showtime's open source Solito app.

Good luck, and let me know if you have any questions on Twitter.