Use-case: let's add an onboarding flow to our Invoicing feature
Let say that we are working on a nice tool helping entrepreneurs running their business with features such as:
- invoicing
- website analytics (conversion, etc...)
- newsletter management
We have to update the rules to access our Invoicing feature with the following:
- if some billing information is missing, show a billing screen that prevents access to the feature (trial end)
- if the user never accessed the feature, display a nice (dismissable) screen
- otherwise, display the default feature screen (invoices list)
Let's see how we could design this new behavior:
The <BrowserRouter> design
If the state linked to the rendering of components can be publicly shown in the URL, most of us will rely on react-router <BrowserRouter> and define an upstream routing logic, as it follows:
Our invoice route component will try to redirect the user before accessing the app if necessary.
This approach is most of the time the “go-to” solution for most user-flow-related logic, here, a user that has missing billing information will be redirected to /onboarding/billing when accessing the feature (at the <Invoice> component mounting phase).
When <BrowserRouter> solution is not possible
Let’s say now that our product team doesn’t want any /onboarding/* URL to be publicly visible in the browser URL, just the /invoices feature path.
In that case, most of us will choose the following implementation:
1// imports ...
2
3const Invoices = () => {
4 const { user, loading } = useCurrentUser()
5
6 const Comp = useMemo(
7 () => {
8 if (loading || !user) {
9 return null
10 } else if (!user.billing) {
11 return <BillingScreen />
12 } else if (user.invoices.length === 0) {
13 return <InvoicesWelcome />
14 } else {
15 return <InvoicesView />
16 }
17 },
18 [user, loading]
19 )
20 return (
21 loading ?
22 <Loader />
23 <Comp />
24 )
25}
26
27export default Invoices
28
<Invoices> instanciated by the <InvoiceRoute> route
This pattern is very common in the industry.
This approach, although valid, has many pitfalls:
- doesn’t scale nicely, with a lot of manual components dynamic instantiation, this approach will be less readable as use-cases count grows
- the way of implementing such an approach will differ depending on the developer (if/else, switch, ternary, etc…)
- sometimes, uses of React.createElement() (when multiple components share the same props) drop the gains in DX of using JSX
The <MemoryRouter> solution
<MemoryRouter> is a <Router> that keeps the history of your “URL” in memory (does not read or write to the address bar). Useful in tests and non-browser environments like React Native.
<MemoryRouter> will allow us to keep the same design used with <BrowserRouter> without having to rely on the browser URL.
Here is an example:
1import {
2 MemoryRouter,
3 BrowserRouter,
4 Switch,
5 Route,
6 Redirect,
7} from "react-router";
8// other imports...
9
10const AppRoot = () => (
11 <BrowserRouter>
12 <Switch>
13 <Route path={"/invoices"} component={Invoices} />
14 {/* other routes: sign-in, logout, billing */}
15 </Switch>
16 </BrowserRouter>
17);
18
19const Invoices = () => {
20 const { user, loading } = useCurrentUser()
21
22 const defaultPath = useMemo(() => {
23 if (loading || !user) {
24 return null;
25 } else if (!user.billing) {
26 return "/onboarding/billing";
27 } else if (user.invoices.length === 0) {
28 return "onboarding/welcome";
29 } else {
30 return "/";
31 }
32 }, [user, loading]);
33
34 return loading ? (
35 <Loader />
36 ) : (
37 <MemoryRouter>
38 <Switch>
39 <Route path={"/"} component={InvoicesView} />
40 <Route path={"/onboarding/billing"} component={BillingScreen} />
41 <Route path={"/onboarding/profile"} component={InvoicesWelcome} />
42 <Redirect to={defaultPath} />
43 </Switch>
44 </MemoryRouter>
45 );
46};
<MemoryRouter> stores the current history state in memory instead of the browser URL, allowing us to use the routing pattern for different similar purposes
Relying on <MemoryRouter> in this specific use-case brings many advantages:
- Familiarity of the react-router API (leverage the team common knowledge)
- Scales to many scenarios without reducing readability
- Leverages the hooks pattern for asynchronous redirection
- The routing logic (here relying on defaultPath and useMemo()) can be extracted and unit-tested in a custom hook
Of course, it also comes with some downsides, so, without further ado, let’s see a complete technical overview of the <MemoryRouter> pattern.
Enjoying this article so far?
Don't miss my latest publications, talks & updates.
The <MemoryRouter> pattern
Let’s analyze our <MemoryRouter> pattern usage for our new user flow:
1import {
2 MemoryRouter,
3 BrowserRouter,
4 Switch,
5 Route,
6 Redirect,
7} from "react-router";
8// other imports...
9
10const AppRoot = () => (
11 <BrowserRouter>
12 <Switch>
13 <Route path={"/invoices"} component={Invoices} />
14 {/* other routes: sign-in, logout, billing */}
15 </Switch>
16 </BrowserRouter>
17);
18
19const Invoices = () => {
20 const { user, loading } = useCurrentUser()
21
22 const defaultPath = useMemo(() => {
23 if (loading || !user) {
24 return null;
25 } else if (!user.billing) {
26 return "/onboarding/billing";
27 } else if (user.invoices.length === 0) {
28 return "onboarding/welcome";
29 } else {
30 return "/";
31 }
32 }, [user, loading]);
33
34 return loading ? (
35 <Loader />
36 ) : (
37 <MemoryRouter>
38 <Switch>
39 <Route path={"/"} component={InvoicesView} />
40 <Route path={"/onboarding/billing"} component={BillingScreen} />
41 <Route path={"/onboarding/profile"} component={InvoicesWelcome} />
42 <Redirect to={defaultPath} />
43 </Switch>
44 </MemoryRouter>
45 );
46};
- Our <AppRoot> BrowserRouter is responsible of the application top-level routing
- <Invoice> is computing a defaultPath given the asynchronous data loaded from useCurrentUser()
- Finally, we leverage <Redirect> from react-router to display the proper component
Adding new business rules would scale without polluting the component's body.
An improvement would be to extract the useMemo() into a custom hook called useInvoiceInitialRoute():
1const { defaultPath, loading } = useInvoiceInitialRoute()
Let's now see some technical considerations to keep in mind while using <MemoryRouter>.
Important technical details
The usage of <MemoryRouter> comes with some technical specificity that must be kept in mind to avoid any issues.
Context history value is overwritten
The first specific aspect comes with the nesting of a <MemoryRouter> under another router (Browser or Memory).
An example will be worth a dozen lines of explanation:
1const MyComponent = () => {
2 const history = useHistory()
3 const goToFeatureA = useCallback(
4 () => {
5 history.push("/feature-a")
6 },
7 [history],
8 )
9 // ...
10}
11
12const Root = () => (
13 <MemoryRouter>
14 <Switch>
15 <Route path="/" exact component={MyComponent} />
16 </Switch>
17 </MemoryRouter>
18)
19
20const App = () => (
21 <BrowserRouter>
22 <Switch>
23 <Route path="/" exact component={Root} />
24 <Route path="/feature-a" exact component={FeatureA} />
25 </Switch>
26 </BrowserRouter>
27)
There is a bug in this code. Did you find it?
The history.push() done in <MyComponent> is addressed to the closest router in the React tree, here: <MemoryRouter>.
This happens for the simple reason that react-router is keeping only a unique context at a time at a given level of the React tree.
In order to perform an action to the top-level <BrowserRouter>, we need to keep a copy of its history object in a custom context as shown below:
1const BrowserHistory = React.createContext(null)
2
3const MyComponent = () => {
4 const history = useContext(BrowserHistory)
5 const goToFeatureA = useCallback(
6 () => {
7 history.push(‘/feature-a’)
8 },
9 [history],
10 )
11 // ...
12}
13
14
15const Root = () => {
16 const history = useHistory()
17 return (
18 <BrowserHistory.Provider value={history}>
19 <MemoryRouter>
20 <Switch>
21 <Route path="/" exact component={MyComponent} />
22 </Switch>
23 </MemoryRouter>
24 </BrowserHistory>
25 )
26}
27
28const App = () => (
29 <BrowserRouter>
30 <Switch>
31 <Route path="/" exact component={Root} />
32 <Route path="/feature-a" exact component={FeatureA} />
33 </Switch>
34 </BrowserRouter>
35)
You can see some minor changes that fix our bug:
- We create a BrowserHistory in order to keep a copy of the <BrowserRouter> history inside a <MemoryRouter> context.
- Our <Root> component is storing the <BrowserRouter> history object into the BrowserHistory context
- <MyComponent> can now access <BrowserRouter> history object through the BrowserHistory context and the <MemoryRouter> history object using useHistory()
Note: I recommend to alias the useContext(BrowserHistory) in a custom hook, like useBrowserHistory() for clarity.
When using the MemoryRouter pattern, always keep in mind that a parent BrowserRouter context will be erased, and its history object must be passed to components below using a custom context.
Get access to useHistory()
As you may notice, a <MemoryRouter>, like any other router, cannot take any default route, the default always being set to /.
In our use-case, we choose to rely on <Redirect> to handle the initial routing.
Let's now see an example using useEffect():
1import {
2 MemoryRouter,
3 BrowserRouter,
4 Switch,
5 Route,
6 Redirect,
7} from "react-router";
8// other imports...
9
10const AppRoot = () => (
11 <BrowserRouter>
12 <Switch>
13 <Route path={"/invoices"} component={Invoices} />
14 {/* other routes: sign-in, logout, billing */}
15 </Switch>
16 </BrowserRouter>
17);
18
19const InvoicesRoot = () => (
20 <MemoryRouter>
21 <Invoices />
22 </MemoryRouter>
23);
24
25const Invoices = () => {
26 const { user, loading } = useCurrentUser()
27 const history = useHistory()
28
29 useEffect(() => {
30 let newPath = null
31 if (loading || !user) {
32 return;
33 } else if (!user.billing) {
34 newPath = "/onboarding/billing";
35 } else if (user.invoices.length === 0) {
36 newPath = "onboarding/welcome";
37 } else {
38 newPath = "/";
39 }
40 if (newPath !== history.location.pathname) {
41 history.push(newPath)
42 }
43 }, [user, loading, history]);
44
45 return loading ? (
46 <Loader />
47 ) : (
48 <Switch>
49 <Route path={"/"} component={InvoicesView} />
50 <Route path={"/onboarding/billing"} component={BillingScreen} />
51 <Route path={"/onboarding/profile"} component={InvoicesWelcome} />
52 </Switch>
53 );
54
55}
As you can see, the only difference is that the routing logic must be nested under the <MemoryRouter> in order to access the history object through useHistory().
You will notice that in our use case, useEffect() is not the proper approach since we need to compute an initial route state asynchronously, not to react to all effects on user.
However, this approach is especially handy when you need to achieve more than initial dynamic routing, for example, change routing dynamically based on state or data changes.
When to use the <MemoryRouter> pattern
We saw how to use the MemoryRouter pattern and its limitation.
Let's now see some examples of good and bad use-case for the MemoryRouter pattern.
Good use cases
The MemoryRouter pattern is bringing significant advantages when:
Building a multi-step UI that requires to:
- instanciate a initial step component given an initial state
- navigate between steps from any step
Building dynamic component rendering that doesn't rely on the browser URL:
- when components rendering depend on some application state or data
(ex: dedicated <Chat> top-component for each chat type (group, 1-1, private))
- when component dynamic rendering logic is reactive to some data or state
Bad use cases
However, the MemoryRouter pattern is bringing more complexity its usage implies:
- Nesting multiple <MemoryRouter>
- As we saw in the "Important technical details" section, nesting routers comes with context specificities and complexity
- A rule of thumb would be to never nest any more than 2 routers
- replacing a simple ternary or if/else condition
- The MemoryRouter pattern won't bring any advantage in replacing simple condition
- The MemoryRouter pattern is only useful to replace complex logic that degrade the readability and stability
Conclusion
Relying on <MemoryRouter> for complex user flow brings many advantages:
- Familiarity of the react-router API (leverage the team common knowledge)
- Scales to many scenarios without reducing readability
- Leverages the hooks pattern for asynchronous work
- The routing logic (ex: defaultPath computation) can be extracted and unit-tested in a custom hook
However, keep in mind that it comes with some technical specificities:
- history overwrites
- useEffect() routing effect should be under the <MemoryRouter>
Finally, please avoid the <MemoryRouter> pattern in the following scenarios:
- Usage that will require the nesting of multiple <MemoryRouter>
- As we saw in the "Important technical details" section, nesting routers comes with context specificities and complexity
- A rule of thumb would be to never nest any more than 2 routers
- Just Replacing a simple ternary or if/else condition
- The MemoryRouter pattern won't bring any advantage in replacing simple condition
- The MemoryRouter pattern is only useful to replace complex logic that degrade the readability and stability