Jay Gould

A plain and separate tabbed view with React Native Navigation

November 25, 2020

Navigation hero image

This short post shows my approach to what I consider a fundamental app navigation setup for React Native which includes a login screen, a tabbed welcome screen (for logged in users), and a sidebar, all done with Wix’s React Native Navigation.

It’s been a couple of years since I’ve done any work with React Native, but with the recent lockdown and a bit of time on my hands during annual leave, I decided to have a bit of a play. React Native has come a long way in a couple of years, but React Native Navigation has changed almost completely. It still has the benefit of being a fully native implementation for navigation (unlike other alternatives), but has a new API and good documentation to match.

The requirements for logged in and logged out app states

Although the documentation for React Native Navigation is detailed, at the time of writing it doesn’t have recipes for the common implementation mentioned above, which was quite difficult to get working how I wanted it. To expand on the setup, I wanted the following functionality:

  • A log in screen which contains login/register process, and no tabs or top bar - only a single screen
  • A welcome screen which is the main bulk of the app, containing a tabbed view
  • A sidebar to appear only on the main app, and not on the login screen
  • The ability for the app to direct the user seamlessly to the logged in or logged out state when the app is opened

The last point there is critical for any production ready app, as you don’t want the user to have to log in each time but it’s also important that the user is directed to the right place without any weird screen transitions or flickers. I’ve approached this with the use of Redux and Redux Persist. Say what you want about Redux but I still think it has a good place in this world!

Installing React Native Navigation

Installing packages like React Native Navigation usually requires a fair amount of digging around in native files on both platforms, but Wix have created a brilliant script to manage all that for us. If you haven’t got React Native Navigation installed yet, be sure to read the docs thoroughly and use this npx rnn-link command.

The above command didn’t finish the installation for me as I was also required to install the pods with iOS, so you may also need to run cd ios && pod install.

Once installed, you’ll need to update the app root to use the React Native Navigation method of registering the main component, replacing the standard method you get on a fresh React Native install. The end result is the following:

// index.js
import { Navigation } from "react-native-navigation"

import App from "./App"

Navigation.registerComponent("com.myApp.WelcomeScreen", () => App)
Navigation.events().registerAppLaunchedListener(() => {
  Navigation.setRoot({
    root: {
      stack: {
        children: [
          {
            component: {
              name: "com.myApp.WelcomeScreen",
            },
          },
        ],
      },
    },
  })
})

Be sure the basic setup as outlined in the RNN docs is working before continuing!

Setting up the navigation stack

The standard setup in the RNN docs needs to be expanded upon to meet our requirements. Start off by updating the main index.js file to include the necessary screen imports and component registrations - you’ll need to create the files in ./src/*:

Examples in the rest of the post are simplified and don’t include any logic for actual authentication or other processing, because the main focus is usage with React Native Navigation.

// index.js

import { Navigation } from "react-native-navigation"
import { initialScreenRoot } from "./src/initialScreenRoot"

import Login from "./src/Login"
import Welcome from "./src/Welcome"
import OtherTab from "./src/OtherTab"
import SideMenu from "./src/SideMenu"

Navigation.registerComponent("Login", () => Login)
Navigation.registerComponent("Welcome", () => Welcome)
Navigation.registerComponent("OtherTab", () => OtherTab)
Navigation.registerComponent("SideMenu", () => SideMenu)

Navigation.events().registerAppLaunchedListener(() => {
  Navigation.setRoot(initialScreenRoot.login)
})

// initialScreenRoot/index.js
export const initialScreenRoot = {
  login: {
    root: {
      stack: {
        children: [
          {
            component: {
              name: "Login",
              options: {
                topBar: {
                  visible: false,
                  title: {
                    text: "Login",
                  },
                },
              },
            },
          },
        ],
      },
    },
  },
  home: {
    root: {
      sideMenu: {
        center: {
          bottomTabs: {
            id: "BottomTabs",
            children: [
              {
                stack: {
                  id: "Tab1",
                  children: [
                    {
                      component: {
                        name: "Welcome",
                        id: "Welcome",
                        options: {
                          topBar: {
                            leftButtons: [
                              {
                                id: "SideMenu",
                                text: "Open",
                              },
                            ],
                            title: {
                              text: "Welcome",
                            },
                          },
                        },
                      },
                    },
                  ],
                  options: {
                    bottomTab: {
                      text: "Welcome",
                    },
                  },
                },
              },
              {
                stack: {
                  id: "Tab2",
                  children: [
                    {
                      component: {
                        name: "OtherTab",
                        id: "OtherTab",
                        options: {
                          topBar: {
                            title: {
                              text: "Other Tab",
                            },
                          },
                        },
                      },
                    },
                  ],
                  options: {
                    bottomTab: {
                      text: "Other Tab",
                    },
                  },
                },
              },
            ],
          },
        },
        left: {
          component: {
            name: "SideMenu",
          },
        },
      },
    },
  },
}

There’s a lot in that snippet, but the important take away is the Navigation.setRoot function which sets the root to be the login part of the initialScreenRoot object. This allows the app to open on the login screen as the first screen. This object structure is how we’re able to get the single login screen, tabbed welcome area, and sidebar all working together in one app, and also what separates the login and home roots.

Later on, the above code will be updated so the root is dynamic and selected automatically based on the users authentication status.

The welcome/home screen would normally need to be pushed to the stack, but in this case we are treating the login and home roots as separate app roots. React Native Navigation offers a way to dynamically change the app root on user action:

// src/Login.jsx

// ... other imports

import { initialScreenRoot } from "./initialScreenRoot"

const Login = ({}) => {
  const signIn = () => {
    // You'll want to add your sign in process here, and the following line should be added in the success
    // handler for when the user has successfully logged in, sending them to the welcome screen
    Navigation.setRoot(initialScreenRoot.home)
  }

  return (
    <SafeAreaView style={{ flex: 1 }}>
      <View style={{}}>
        <Text>Login screen</Text>
        <TouchableOpacity
          style={{}}
          onPress={() => {
            signIn()
          }}
        >
          <Text>Sign in </Text>
        </TouchableOpacity>
      </View>
    </SafeAreaView>
  )
}

export default Login

Once the user taps the sign in button, the app root is changed and the user is presented with the tabbed layout, home root.

The reverse needs to be implemented to log the user out. I’ve placed my logout button in the side menu, so here’s a small snippet to show how to open the side menu from within the welcome screen:

// src/Welcome.jsx

// ... other imports

const Welcome = (props) => {
  useEffect(() => {
    const navigationButtonEventListener = Navigation.events().registerNavigationButtonPressedListener(
      ({ buttonId }) => {
        if (buttonId === "SideMenu") {
          // If listener detects a press of the "SideMenu" button, show the side menu
          Navigation.mergeOptions(props.componentId, {
            sideMenu: {
              left: {
                visible: true,
              },
            },
          })
        }
      }
    )
    return () => {
      navigationButtonEventListener.remove()
    }
  }, [])

  return (
    <>
      <SafeAreaView style={{ flex: 1 }}>
        <View>
          <Text>Welcome</Text>
        </View>
      </SafeAreaView>
    </>
  )
}

The side menu can be opened when tapping any button in the app, but I’ve chosen to link it to the top bar button with ID of SideMenu as defined in the initialScreenRoot object earlier. All navigation buttons in React Native Navigation need to be manually listened to on the screen you want, using the registerNavigationButtonPressedListener function as shown above.

Finally, the side menu is shown below:

// src/SideMenu.jsx

// ... other imports

import { initialScreenRoot } from "./initialScreenRoot"

const SideMenu = ({}) => {
  return (
    <>
      <SafeAreaView style={{ flex: 1 }}>
        <View>
          <Text>Side Menu</Text>
          <TouchableOpacity
            onPress={() => {
              Navigation.setRoot(initialScreenRoot.login)
            }}
          >
            <Text style={{ color: "blue" }}>Log Out</Text>
          </TouchableOpacity>
        </View>
      </SafeAreaView>
    </>
  )
}

export default SideMenu

The logout press action above will log the user out and replace the current app root, home, with the logged out app root, login.

You can browse the whole project up to this point by checking out this commit in my example repo.

Adding auth persistence and directing the user to the logged in area

At this point the app works as expected when you tap the login button that directs you to the tabbed view with the side bar. However if you close the app down and re-open it, you’re taken to the login screen again. In a production ready app you may want this to take you straight back to the tabbed view if the user is already authenticated. This involves 2 parts:

  • Storing the user’s authentication state on the device
  • Having React Native Navigation direct the user to the logged in area when the app is opened

Integrating Redux

Start off by installing the followin packages to do the magic behind the scenes:

npm i redux react-redux redux-persist @react-native-community/async-storage

You may need to run pod install after installing the async-storage package.

This post assumes knowledge of Redux so I won’t go in to loads of detail here, but I’ve done a few posts about Redux integration a while back you feel free to check them out. Here’s my Redux store setup:

// src/store/index.js

import React from "react"
import AsyncStorage from "@react-native-community/async-storage"
import { createStore } from "redux"
import { Provider } from "react-redux"
import { persistStore, persistReducer, getStoredState } from "redux-persist"
import { PersistGate } from "redux-persist/integration/react"

import reducer from "../reducers/rootReducer"

const persistConfig = {
  key: "rootKeyPersist",
  storage: AsyncStorage,
}
const persistedReducer = persistReducer(persistConfig, reducer)

const store = createStore(persistedReducer)

const persistor = persistStore(store)

export const withReduxProvider = (Component) => (props) => (
  <Provider store={store}>
    <PersistGate loading={null} persistor={persistor}>
      <Component {...props} />
    </PersistGate>
  </Provider>
)

export const isUserLoggedInFromPersist = async () => {
  const persistedState = await getStoredState(persistConfig)
  return persistedState?.authReducer?.loggedIn || false
}

The Redux Persist is initiated using the reducers which are imported from another file, and the persist setup is passed in to the normal Redux setup function, createStore. The Redux provider and persistor are then wrapped around the imported Higher Order Components (HOC) and the whole wrapped component is exported for use later. This exported component, withReduxProvider, is used in our main index.js file which I’ll come on to next.

The isUserLoggedInFromPersist function is a separate function which uses the Redux Persist functionality so I decided to keep it in the same file above. This gets the user’s logged in state from the authReducer and is also used in the main index.js file.

Opening the app to the logged in area if the user is authenticated

The main file which ties all the above work together is the main index.js file:

import { Navigation } from "react-native-navigation"

import { withReduxProvider, isUserLoggedInFromPersist } from "./src/store"

import { initialScreenRoot } from "./src/initialScreenRoot"

import Login from "./src/Login"
import Welcome from "./src/Welcome"
import OtherTab from "./src/OtherTab"
import SideMenu from "./src/SideMenu"

Navigation.registerComponent(
  "Login",
  () => withReduxProvider(Login),
  () => Login
)
Navigation.registerComponent(
  "Welcome",
  () => withReduxProvider(Welcome),
  () => Welcome
)
Navigation.registerComponent(
  "OtherTab",
  () => withReduxProvider(OtherTab),
  () => OtherTab
)
Navigation.registerComponent(
  "SideMenu",
  () => withReduxProvider(SideMenu),
  () => SideMenu
)

Navigation.events().registerAppLaunchedListener(async () => {
  ;(await isUserLoggedInFromPersist())
    ? Navigation.setRoot(initialScreenRoot.home)
    : Navigation.setRoot(initialScreenRoot.login)
})

The withReduxProvider HOC from the previous section is imported here, and wrapped around each React Native component, which is crucial, like the normal web usage of Redux, to give each part of our application access to the Redux store. This is where the magic of Redux is, because we’re able to update and access any information, including auth information, from anywhere in the app.

The final and most critical step for user experience is allowing the app to open in the logged in area when the user has previously logged in. This is done in the updated registerAppLaunchedListener function, which now uses the newly made isUserLoggedInFromPersist function from the previous section to check the Redux store for user authentication information, and automatically replace the screen root for us before the app has loaded.

On first login, the isUserLoggedInFromPersist will of course return false because there’s nothing in the store, but the store will be updated on login with the following action from Redux:

// src/Login.jsx

// ... other imports

const Login = ({}) => {
  const dispatch = useDispatch()

  const signIn = () => {
    // Dispatch login action to store user's auth state in persisted Redux store
    dispatch({
      type: "SET_LOGIN_SUCCESS",
      userInfo: {
        name: "Moira Rose",
      },
      authToken: "some-token",
    })

    Navigation.setRoot(initialScreenRoot.home)
  }

  return (
    <>
      <SafeAreaView style={{ flex: 1 }}>
        <View style={{}}>
          <Text>Login screen</Text>
          <TouchableOpacity
            style={{}}
            onPress={() => {
              signIn()
            }}
          >
            <Text>Sign in </Text>
          </TouchableOpacity>
        </View>
      </SafeAreaView>
    </>
  )
}

export default Login

Similarly, the same store values can be updated to remove the auth state when the log out button is pressed, dispatching another Redux action:

// src/SideMenu.jsx

// ... other imports

const SideMenu = ({}) => {
  const dispatch = useDispatch()

  return (
    <>
      <SafeAreaView style={{ flex: 1 }}>
        <View>
          <Text>Side Menu</Text>
          <TouchableOpacity
            onPress={() => {
              dispatch({
                type: "SET_LOGOUT",
              })

              Navigation.setRoot(initialScreenRoot.login)
            }}
          >
            <Text style={{ color: "blue" }}>Log Out</Text>
          </TouchableOpacity>
        </View>
      </SafeAreaView>
    </>
  )
}

export default SideMenu

And that’s it - each time the app is opened, the persisted Redux store is checked for authentication status and the user is presented with the correct screen. There’s no flickering or jumpy screen transition, it just works.

To see all the code for this mini project you can view the repo. It’s a specific repo containing the code for this post and I’ve purposely left it basic with no styling or other packages.

Improvements and next steps

As it’s only a basic implementation you’ll want to address a few things if using this approach to build a production ready app. You’ll want to add real authentication which uses a remote database to securely store usernames/passwords of your users.

This approach with React Native works well with JWT tokens, so you’d be able to store all or part of the JWT in async storage, but be careful with the JWT setup. As this is an app, no data stored in async storage is safe from those pesky hackers so have a good read around official docs and decide what solution best fits your situation. You may be ok storing your JWT In async storage if your app only handles limited user information, but perhaps not for payment or other sensitive personal data.

Thanks for reading!


Senior Engineer at Haven

© Jay Gould 2023, Built with love and tequila.