React Native - How to better organise your app with react-native-navigation
Alex R.
Posted on June 25, 2022
If you are looking for a biased way of structuring and reasoning about your native app then, you've landed in the right place. This post is actually a part of a mini series entitled 'React Native - And the rest' where we tackle common problems encountered in many many projects and teams.
In this part of the series we'll dive a little deeper and tackle the general strategy that can help your React native app scale its navigation with the react-native-navigation library.
As some of you may already know the setup is a bit more special in this library's case because of how the "native" bindings work in this package, thus you will have to setup multiple "principal" rendering trees if you like to have that perf edge - essentially each screen, modal, overlay is going to be hooked to custom factories that will guarantee consistency and give your app a good structure - consistency is the overall goal.
Although we are using the react-native-navigation many of the patterns here also apply for the react-navigation library.
If you want me to make an alternative blog about that, please leave me a note in the comments.
Acknowledging the challenging bits
- Many ideas out there - some work, and some really don't.
- Not separating properly your screen logic from everything else (middleware, fixtures, components) will force you to take very bad decisions, many on Friday afternoons.
- Importing modules mess and cyclic dependencies may kill the app, stumble the bundler and lead to bad experiences - both for user and engineers.
- Not having a general & guaranteed "outer" shell for most of your screens - yes, you will have to treat exceptions differently, tends to encourage spaghetti code.
- Not having the flexibility to test in isolation and interchange the potential different rendering trees (see eg below) as you please will slow you down.
βπ» A small word of caution: I am a bit biased towards a declarative style of writing program logic and you'll find plenty of that in all my online examples.
In my experience a more readable codebase yields less bugs over the product's lifetime.
If it gets smelly or performance takes a big hit I don't shy way from a more imperative style of writing - it's just a tool.
Initial Setup:
- Keep all your screens in one export and import then under a single name space like
import * as screens from ./screens
. This will ensure that you can operate on that object just like any other regular object with enumerable entries.
βπ» [RAM Bundles](https://reactnative.dev/docs/ram-bundles-inline-requires):
If you intend to use RAM bundles for extra performance, you will need to use *inline require(...)* on your actual screen files (same for modals, overlays, etc.) instead of exporting everything in a `folder/index` fashion.
- Now let's create two new factory functions that will act as React component tree generators. Let's say we want one tree generator for all our "pages" and one for all our misc components (eg: think dynamic header components that need to be registered as well with the navigation bridge).
This will enforce a single entry point and a clear path describing your actual app skeleton and it will also help you keep your sanity intact as your app grows larger and larger. Having a single point of entry for all your providers is a must.
Remember that we want to leverage React's context API for many of these providers.
- While creating these two factory functions try to build them as light as possible and really think about the order in which you declare them.
Below is an example of such a component tree generator function for an app screen (of course you are free to build other ones for components that happen to need native navigation bindings (top bars, bottom bar components, anything that an "app" screen , you usually want to split these because of performance considerations or lighter context because you usually don't need all the stores or services available for these types of components).
Example:
export const registerScreen = (Component, store, props) => {
const ComponentWithGestureHandler = gestureHandlerRootHOC(Component)
return (
<Provider store={store}>
<LockscreenContainer {...props} currentComponentId={currentComponentId} />
<ThemeContextProvider>
<ErrorBoundaryContainer>
<OfflineContainer {...props} currentComponentId={currentComponentId}>
<BackgroundContainer {...props}>
<ServicesContextProvider services={ServicesGlobalStore}>
<ComponentWithGestureHandler {...props} />
</ServicesContextProvider>
</BackgroundContainer>
</OfflineContainer>
</ErrorBoundaryContainer>
</ThemeContextProvider>
</Provider >
)
}
Let's break this example down:
Theming where it makes sense <ThemeContextProvider {...}/>
In my previews post we've covered in detail how to design and write a simple and solid production level example that can power your app with multi theming support.
This provider will ensure that the correct theme will correctly propagate to the other lower layers of the app. More details in this [[React Native - How to approach design collaboration]] and also [[React Native - How to scale your team, design and code]]
The state provider <Provider {...}
:
Here you can insert most of your state in a consistent manner.
Depending on your existing state dependencies and your current app structure you can freely move this around in the example app tree.
βπ» The container components:
These "containers" should serve a single function really well. They should also live somewhere in the source code where you can implement in isolation their own separate needs like tests interfaces & types, etc. - also you should rely on type inference as much as you can when using types.
Handling lock screens states <LockscreenContainer {...}
:
This is where you would want to listen to your global app state (eg: if your app is in background, foreground, inactive, etc.) and make the proper styling and "lock-screen" specific logic. You can for instance decide to lock out users depending on multiple conditions and have all of that expressed in a single line.
Remember:
The way `react-native-navigation` works is that it can have multiple entry points bound to different navigation strategies (independent stacks). More on that here [Root registration](https://wix.github.io/react-native-navigation/docs/root):
It is not your typical example React app with one entry point and a single rendering tree that defines all your app structure.
Handling errors gracefully:
<ErrorBoundaryContainer {...props}/>
With this the aim is pretty obvious, but it guarantees a fallback UI will be there on each new screen mount - leveraging the "componentDidCatch()" or the "getDerivedStateFromError()" methods to gracefully handle errors. For best results, this container should be assembled from at least two parts for decoupling points: the "UI/UX" one and the logic one.
Handling offline states:
<OfflineContainer {...props}/>
This is the perfect place to think about how to manage the offline and online states both from a UI/UX perspective, but also from a code modularity and readability perspective. You can of course choose to get lost with some tangled epics or sagas to manage complex online/offline states, but why not have a global entry point for those dispatches, plus it's declarative and isolated.
The background layout:
<BackgroundContainer {...props}/>
It may be an optional thing, but instead of redeclaring the same style for each screen or reuse the same background styling, why not have a higher order component that guarantees that. If you need exceptions you can always treat them individually in your screen class/function.
Consuming services:
<ServicesContextProvider {...props}/>
Leveraging the React context APIs with inversion of control and reflection (using class decorators OR some good 'ol fashion higher order functions) can be a very powerful combo.
It can empower you to inject the things you want, where you want them - that simple. In this example I want to consume some service instances in my component screens without going through middle-where - but this really depends on your particular app requirements and existing architecture.
Finally our screen component:
<ComponentWithGestureHandler {...props}/>
This is your last chance to prepare the last bits of props or configs for your app screen in order to have everything setup in one place. I've found this setup to be ideal because I can write my tests in a very straightforward, flexible and predictable way.
Everything else that is local to the screen is in that screen's file (class or FC implementations).
Integrating our screens factory function with RNN
:
1. Writing some helper function to register our components:
import React from 'react'
import { Navigation } from 'react-native-navigation'
import { ComponentProvider } from "react-native";
// A simple extraction function
const extractComponentFromNodeRequire = (module: NodeRequire) => {
const exportedComponentClassName: string = Object.keys(module)?.[0]
const Component: React.FunctionComponent<any> = module[exportedComponentClassName]
return {
componentName: Component?.name, // A static property on the component implementation
Component: Component, // The component reference
}
}
// The actual binding with RNN (react-native-navigation):
const registerComponentsWithNavigation = (modules: NodeRequire[], registerFn: (Component: React.FunctionComponent<any>, props: any) => JSX.Element) => {
modules.forEach((module: NodeRequire) => {
const {
componentName,
Component,
} = extractComponentFromNodeRequire(module)
const componentProvider: ComponentProvider = () => (props: any) => registerFn(Component, props)
const concreteComponentProvider: ComponentProvider = () => Component
Navigation.registerComponent(
componentName,
componentProvider,
concreteComponentProvider,
)
})
}
2. Registering our first component:
const screenModules: NodeRequire[] = [
require('src/screens/Home')
]
const otherModules: NodeRequire[] = [
require('src/components/GenericHeader')
]
// Now we just pass our `Example 1` function as a reference to our helper function and have all our `screenModules` array automatically binded to RNN:
registerComponentsWithNavigation(screenModules, registerScreen)
3. Bonus points
As a bonus, you can try to write a new factory function for all your other components. A fairly common use case is the one where you would want to register custom screen headers or footers that need to be hooked up to the RNN
runtime.
const otherModules: NodeRequire[] = [
require('src/components/GenericHeader')
]
// Now you just pass your own function as a reference to our helper function above and we should have all our `otherModules` array automatically binded to RNN:
registerComponentsWithNavigation(otherModules, registerComponent)
Wrapping up
I really hope you enjoyed this part, managing complex navigation patterns with an ever evolving ecosystem of frameworks and tools can be quite a daunting task, so why not try to simplify a little.
At this point don't have a clear preference over which package to use in my next project (react-navigation or react-native-navigation) - they both have strengths and weaknesses, one is easier to actually work with, one is faster in terms of execution, one has better APIs, docs, community, etc.
You you want to write about a deep dive, please reach out.
If you like to see more content from me, you can show your support by liking and following me around. I'll try my best to keep articles up to date.
As always, stay humble, learn.
π Hey, if you want to buy me a coffee, here's the Link
Posted on June 25, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
June 25, 2022