Last time we had a look at a more complex navigation flow using the AuthFlow Pattern. This time we will look into different types of nested navigations and adjust our main navigation flow a bit more to take care of persisted user data/login state and such.
Nested Navigation inside a screen
Here is a quick preview of what we will start with today.
For our first trick, we need a new type of navigation so we will grab it from npm real quick. I'll show you how to set up a nice and pre-styled material design tab navigation for our settings page.
- @react-navigation/material-top-tabs
- react-native-pager-view
- react-native-tab-view
expo install @react-navigation/material-top-tabs react-native-pager-view react-native-tab-view
On our SettingsScreen.tsx we will use a new component with some simple mocked pages, including one really long scrollable list. We also import our createMaterialTopTabNavigator element from the material-top-tabs package to define the navigator we will implement within our page.
// File: src/components/demo/Settings.tsx
import React from 'react'
import { StyleSheet, View, Text, ScrollView } from 'react-native'
import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs'
const Tab = createMaterialTopTabNavigator()
const TabOne = () => (
<View>
<Text>Tab One</Text>
</View>
)
const TabTwo = () => (
<ScrollView>
<Text />
{Array(42)
.fill('Tab Two')
.map((text: string, index) => {
const tabContent = `${text} (${index})`
return <Text key={tabContent}>{tabContent}</Text>
})}
</ScrollView>
)
const TabThree = () => (
<View>
<Text>Tab Three</Text>
</View>
)
const SettingTabs = () => {
return (
<Tab.Navigator>
<Tab.Screen name="SettingsOne" component={TabOne} />
<Tab.Screen name="SettingsTwo" component={TabTwo} />
<Tab.Screen name="SettingsThree" component={TabThree} />
</Tab.Navigator>
)
}
const Settings = (): React.ReactElement => {
return (
<View style={styles.settingsBox}>
<SettingTabs />
</View>
)
}
const styles = StyleSheet.create({
settingsBox: {
backgroundColor: '#ccc',
width: 300,
height: 500,
borderWidth: 4,
borderColor: '#000',
borderRadius: 5,
},
})
export default Settings
A small detail you will need to keep in mind when nesting navigators is that each navigator keeps track of its own history, params and options.
When navigating, React Navigation will search in its current scope for a navigator. If none is present, it will bubble up a level to the parent components until it finds a navigator to perform navigation actions on. For additional details, you should take the time (5 minutes tops) to read the official documentation.
// File: src/screens/SettingsScreen.tsx (partial)
const SettingsScreen = ({ navigation }: SettingsScreenProps): React.ReactElement => (
<View style={styles.page}>
<Text>SETTINGS</Text>
<Settings />
<Button title="back" onPress={() => navigation.goBack()} />
</View>
)
When we add our new Settings component to our SettingsScreen, we now have a nested in-page navigation element that works independently from its parent navigation container.
Nested Navigation inside a navigator as a screen
Instead of placing a navigatable component within your screen, you can also use it as a screen on its own. Let's say our home screen consists of 3 different pages you can switch between.
For this example, we will use a more bare bone basic bottom-tabs navigator.
expo install @react-navigation/bottom-tabs
For this example, I will simply copy and adjust our existing home screen. Feel free to flesh it out a bit more to get something less copypasta and more life-like, if you want. You could turn them into a news, home and category screen for your app or something along those lines.
// File src/screens/HomeScreenB.tsx // and HomeScreenC.tsx
import React from 'react'
import { Text, View, StyleSheet, Button } from 'react-native'
import { MainNavigationProp } from '../../routing/types'
import { MainRoutes } from '../../routing/routes'
type HomeScreenProps = {
navigation: MainNavigationProp<MainRoutes.Home>
}
const HomeScreenB = ({ navigation }: HomeScreenProps): React.ReactElement => (
<View style={styles.page}>
<Text>Seection B</Text>
<Button title="settings" onPress={() => navigation.navigate(MainRoutes.Settings)} />
</View>
)
const styles = StyleSheet.create({
page: {
flex: 1,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
},
})
export default HomeScreenB
Now go to the routes.ts file and add our new routes. We'll define a new enum for the HomeRoutes and use it to create our HomeTabsParamList as we did before with our MainStackParamList.
In the MainNavigation component, we can now create a new Navigator using our HomeTabs instead of the MainStack and add 3 screens to it, our existing home screen and the 2 new ones.
A bit further down, we replace the HomeScreen component with our new Home Navigator component and that's it.
And that's it, we now have a 3-tabs nested navigator without our main stack on the same level as the settings screen.
Adding Persistence to our Redux Store
You might want to keep some user data stored locally between sessions. For that, we can make use of redux-persist and react natives async storage.
I've already started writing a separate article about Redux, Persist and different way to consolidate and rehydrate state data including ways to migrate old persisted data when the app changed. For now, I'll stick to the basics.
expo install redux-persist @react-native-async-storage/async-storage
For now, we want to make the user data persistent. This means that upon re-opening the app, the user email and logged in state will still be set so that we can skip the login screens.
Redux persist requires a few minor changes to our redux code and App file. First of all, we need to import AsyncStorage as well as persistStore and persistReducer in our central redux file.
// File: src/redux/index.ts (partial 1)
import { persistStore, persistReducer } from 'redux-persist'
import AsyncStorage from '@react-native-async-storage/async-storage'
const persistConfig = {
key: 'root',
storage: AsyncStorage,
whitelist: ['user'],
}
const persistedReducer = persistReducer(persistConfig, rootReducer)
The persist config contains a key, our storage engine and a whitelist of the reducers we want to rehydrate when we restart the app. Key and the storage engine are required while the whitelist attribute is optional. We could go with a whitelist (mark what we keep), blacklist (mark what we don't want to keep) or neither and keek all the data.
I prefer whitelisting for this because I'm more in control when I later add more and more reducers that I might never need on the next startup. Persisted data should always serve a real purpose and should be chosen wisely. Hence, whitelists are the way to go.
When we set up our store with configureStore, we need to make 2 small changes. Instead of our rootReducer, we now use the persistedReducer (containing our rootReducer) and as we are currently using the serializableCheck middleware that comes with Redux Toolkit, we need to put a few things on the blacklist so they don't get checked. The actions you see here under ignoredActions are all imported from redux-persist as well.
// File: src/redux/index.ts (partial 2)
const store = configureStore({
reducer: persistedReducer,
middleware: getDefaultMiddleware =>
getDefaultMiddleware({
serializableCheck: {
/* ignore persistance actions */
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
},
}).prepend(rootMiddleware),
})
export const persistor = persistStore(store)
In addition to our regular redux code, we will also export a persistor, a higher-order function using our store. You will see in a moment, what we need it for.
Moving on to our central entry point, we need to make a small adjustment to our code. We are importing a PersistGate from the react specific module of redux-persist and our new persistor and we will wrap everything inside our Redux Store Provider in the PersistGate component.
// File: App.tsx
import React from 'react'
import { Provider } from 'react-redux'
import { StatusBar } from 'expo-status-bar'
import { enableScreens } from 'react-native-screens'
import { PersistGate } from 'redux-persist/integration/react'
import store, { persistor } from './src/redux'
import MainNavigation from './src/routing/MainNavigation'
enableScreens()
export default function App(): React.ReactElement {
return (
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
<StatusBar hidden />
<MainNavigation />
</PersistGate>
</Provider>
)
}
The PersistGate will serve as a gatekeeper that will not let the user pass until the state has been rehydrated with the stored values. This will prevent a lot of bugs and issues to occur from working with an incomplete or wrong state in the redux store. Additionally, you can register a component with the PersistGate using the loading={} attribute. This component will b shown until the store is ready to be used again.
Persisted Login in our Navigation Flow
For our final adjustments to the authentification and navigation flow, I will briefly skip over some simple parts that are mostly variations of what we did before.
Our plan is as follows. A new user goes through the whole authFlow as before while a logged-in user skips a few screens.
- => start with Auth Stack
- Splash
- AppCheck
- Login/SignUp (skip when already logged in)
- AppLoading
- => switch to App Stack
- Home / Settings / etc
A new slice for our store
First of all, we need to add a new reducer to our store. For now, we will only put the app state (app finished all checks and loading and is now properly running) here but it is important to NOT put this on the whitelist. We want this to be reset at each start of the app.
// File: src/redux/ducks/appState.ts
import { createAction, createReducer } from '@reduxjs/toolkit'
import { RootState } from '../index'
type AppState = {
isRunning: boolean
}
const initialState: AppState = {
isRunning: false,
}
export const setRunning = createAction('[APPSTATE] Set Running', (running: boolean) => ({
payload: {
running,
},
}))
export const selectIsRunning = (state: RootState): boolean => state.appState.isRunning
const appStateReducer = createReducer(initialState, builder => {
builder.addCase(setRunning, (state, action) => {
const { running } = action.payload
return {
isRunning: running,
}
})
})
export default appStateReducer
Then we will replace the navigation and useEffect from the AppLoadingScreen and replace it with our new redux action in a useFocusEffect. The idea is to check all user data on this screen and once everything is checked and loaded, the user is switched over to the other navigation stack.
// File: src/screens/AppLoadingScreen.tsx (partial)
import React, { useCallback } from 'react'
import { Text, View, StyleSheet } from 'react-native'
import { useFocusEffect } from '@react-navigation/native'
import { useReduxDispatch } from '../../redux'
import { setRunning } from '../../redux/ducks/appState'
const AppLoadingScreen = (): React.ReactElement => {
const dispatch = useReduxDispatch()
useFocusEffect(
useCallback(() => {
setTimeout(() => {
/*
* fake timer where you would instead
* load and check the user data before
* you send the user to the App Stack
*/
dispatch(setRunning(true))
}, 1500)
}, [dispatch]),
)
return (
<View style={styles.page}>
<Text>loading User Data...</Text>
</View>
)
}
In our MainNavigation, we need to make 2 smaller adjustments. For one we will now put the AppLoading screen into the auth stack (last screen). The second change is to switch from selectLogin to selectIsRunning.
On our login and signup screens, we will add a watcher to the login state and trigger a navigation action when isLoggedIn changes and is set to true.
Navigating based on state
On our AppCheck, we will take a look at the login state for our user and direct signed in users directly to the AppLoading while everyone else will see the sign in next.
Keep in mind that we need to use a useCallback hook here because otherwise our getRoute would be recreated on every render and trigger our other useCallback that is responsible for our login.
// File: sc/screens/AppCheckScreen.tsx (partial)
import React, { useCallback } from 'react'
import { Text, View, StyleSheet } from 'react-native'
import { useFocusEffect } from '@react-navigation/native'
import { MainNavigationProp } from '../../routing/types'
import { MainRoutes } from '../../routing/routes'
import { useReduxSelector } from '../../redux'
import { selectLogin } from '../../redux/ducks/user'
type AppCheckScreenProps = {
navigation: MainNavigationProp<MainRoutes.AppCheck>
}
const AppCheckScreen = ({ navigation }: AppCheckScreenProps): React.ReactElement => {
const isLoggedIn = useReduxSelector(selectLogin)
const getRoute = useCallback(() => (
isLoggedIn
? MainRoutes.AppLoading
: MainRoutes.SignIn
), [isLoggedIn])
useFocusEffect(
useCallback(() => {
setTimeout(() => {
/*
* fake timer where you would instead
* load and check the app version
* and possible breaking changes before
* you send the user to the App Stack
*/
navigation.navigate(getRoute())
}, 1500)
}, [navigation, getRoute]),
)
return (
<View style={styles.page}>
<Text>loading App Data...</Text>
</View>
)
}
The last thing to do now is to adjust our userReducer so that when a user is logged our not only his login status changes but also the appStatus. This will cause him to be switched back to the AuthFlow navigation stack where he starts on the splash screen again and can log in or sign up as a different user.
Wrapping Up
I think we've covered a lot of ground today and learned a few useful techniques when working with navigation. Next time we will look into styling react native components with … styled-components, one of a few good solutions for writing CSS in JS. A few years back this sentence would have made my skin crawl but bear with me and I will show you how to do so and NOT feel like betraying years of Frontend Developer Experience.
Top comments (1)
Your guide is great, thanks for doing this!
Could you please show, with the current set-up at step 5, how to navigate from one navigator to another? E.g. from HomeA to Settings? The typescript for that is a bit tricky I find.