An interesting alternative to React Router is the lightweight Javascript package Universal Router (called UR here). It integrates nicely in a React app whenever you use a state management library. Below is an illustration of how to combine Universal Router with Mobx using only functional stateless components.
The best is to take an example: an app with a navbar where you can set an "admin" mode to protect routes, where you can search for an item and where you want to open a page which data fetched asynchronously.
To do this in a standard 'React' way , you could use useContext
(or localStorage) for the switch to "admin" mode. For the data uploading, the component-page would hold an internal useState
which will be mutated via fetch
within a useEffect
. If you need to use this data elsewhere, you would need to lift state up to a common ancestor.
With Mobx, we use a central state object -called store
here- that distributes among the stateless React components. This also follows the Mobx principals: pass the full reference of the store and decompose it late, only when needed. Here, the store
will contain the "mode" and the uploaded data and mutating actions. Used together with Universal Router, the process is different from a React / React Router way: the router will act as a controller, will distribute the state where needed, can contain some logic and will trigger the rendering.
The key points are:
- both Mobx and Universal Router use a plain Javascript object.
- the Mobx
store
is distributed within the React components by adding an object with valuestore
to the Universal Router objectcontext
, - the path objects defined for Universal Router need to be orderly sequenced. They contain an
action
with the logic for the rendering a component, - Mobx reactivity relies on the proxying methods
observable
on thestore
andobserver
on components who uses thestore
. Calls to thestore
attributes may need the "autorun" form of MobxrunInAction
, especially with async updates or when called from outside an observed React component.
Setup
imports: besides React, our imports are "mobx", "mobx-react-lite", "history" and "universal-router". This adds an extra 50kb.
linting: we use
React.StrictMode
and the debugconfigure
from Mobx.
New objects
We will build two plain Javascript objects:
store
: the global state object - proxied by theobservable
method - is a collection of attributes and methods that can be use by any React component. Any method that changes the state variables is wrapped withaction
.routes
: it is an array of "path" objects that will be parsed by Universal Router. Each contains at least 2 or 3 keys,path
andaction
orchildren
(when the routes are nested). Note that these are UR keywords. They are in the form:
{ path: "/", action: ()=> {...}}
{path: "/", children: [{...},{...}]
How is routing like with U.R.?
We build a routes
array by appending in an ordered manner path objects. It is in the form:
routes = [
{path:"/", action: () => {...<Home/>..}},
//-> "/"
{path:"/todo",
children: [
{path:"/", action: () => {...<Todo/>..}},
//-> "/todo"
{path: "/:id", action: ()=> {Todo id={id}../>}}
//-> "/todo/1"
]
]
The action
can be asynchronous, just write:
async action (context){ await..}
This routes
array is the input of the object:
new UniversalRouter(routes)
How do you pass props to the path objects?
The routes
array is not declared in a React component so isn't integrated within a component. This is different from React Router where you would declare any routes within a React component and where the app is wrapped with the BrowserRouter
context. We may need a library that handles the state and the connection between Mobx and UR is simple: UR provides a context
object which is available to any of the path objects. We can just add the store
object to the context
object. The context is then added to the constructor. For example, we pass two objects {store:store}
and {mode: "admin"}
to context
:
new UniversalRouter(routes, { context: {store, mode: "admin" }}
The index.js
It contains the path listening, path resolving and the rendering methods.
Firstly we can use history = createBrowserHistory()
from the package history
.
#history.js
import { createBrowserHistory } from "history";
export default createBrowserHistory();
We implement a listener on the History API. An <a/>
link triggers a history.push("/path-to-go")
, and the listener will pass path-to-go
to the unique .resolve()
function attached to the routes array. The routes
are parsed and return the matching path object found or not. The matched path object has an action
(keyword for UR in this context) that renders some object. It can be a React element, and in this case, we can feed ReactDOM.render
with it. This is implemented in index.js.
#index.js
import { createElement as h } from React;
import { render } from "react-dom";
import UniversalRouter from "universal-router";
import { routes } from "./routes.js";
import store from "./store.js";
const root = document.getElementById("root");
const router = new UniversalRouter(routes, { context: {store, mode: "admin" }}
async function renderRoute(location) {
try {
const page = await router.resolve({
pathname: location.pathname
});
if (page.redirect) {
return history.push({ pathname: page.redirect, search:"" });
}
return render(<React.StrictMode>{page}</React.StrictMode>, root);
} catch (err) {
return render(h("p", {}, "Wrong way"), root);
}
}
history.listen(({ location }) => renderRoute(location));
renderRoute(history.location);
Example
The components are wrapped by a Navbar where we can set/unset the mode. On a page, we fetch async data from an API. We can navigate to nested User page via a direct link, or via a search. We can even display a further nested page.
Mobx store
In the example, we have two attributes, users:[]
and modeAdmin: ""
and four methods that change state. One is async. In the case of async actions, we need to use the IIEF form action(()=> [todo])()
aka as runInAction
.
#store.js
import { observable, action, runInAction } from "mobx";
const store = observable({
users: "",
addUsers: action((data) => (store.users = data)),
fetchUsers: async () => {
const query = await fetch(url);
const response = await query.json();
runInAction(() => (store.users = response.data));
},
findUser: function (id) {
return store.users.find((user) => {
return user.id === +id; //<- it's a string!
});
},
modeAdmin: "",
toggleMode: action(() =>
store.modeAdmin === ""
? (store.modeAdmin = "admin")
: (store.modeAdmin = "")
),
})
An example of routes
The router will also be our controller. It contains some logic, call the data and renders the components.
There are several possible implementations. This example uses the middleware construction with context.next()
to build the routes array. This way, we can easily wrap any component with the navbar. We can also use Suspense/lazy
or directly import
for code splitting. We can also implement logical redirections, implement functions before returning the component such as data loading....
#routes.js
import React,{Suspense, lazy} from "react";
import LazyNavBar = lazy(()=>import("./NavBar"))
const routes = [
{
path: "",
async action(context)=>{
const content = await context.next()
if (content.redirect) { return content}
return(
<Suspense fallback={<Spinner/>}
<LazyNavbar>{content}</LazyNavbar>
</Suspense>
)
},
children:[
{
path: "/",
async action() {
const {default: Home} = await import("./Home")
return <Home store={store}/>
},
},
{
path: "/users",
action: async(){ ....}
},
],
},
{
path: "(.*)",
action: () => React.createElement("h1", {}, "404: No way"),
},
]
Redirection
We implement a redirection / protected route via a checkbox in the navbar which sets the modeAdmin
attribute from the store
. We passed an object {mode: "admin"}
and the store
to UR context
, so both values are available in the routes
and we can compare them. Then it is interesting to see how Universal Router implemented a redirection. In short, if the previous condition is false, return an object {redirect: "/a"}
instead of a React element, and let next()
return it too, and make .resolve()
conditionally push the value "/a" to the history when the key "redirect" is present, so this loop backs and eventually renders the React element attached to the path "/a".
Note that we can't use directly store.modeAdmin
in the routes as the Mobx linting will complain "..being read outside a reactive context". This isn't indeed called within an observed React element thus we have to use the "autorun" runInAction
to pass.
#routes.js
let whichMode;
runInAction(() => (whichMode = store.modeAdmin));
if (whichMode !== context.mode) { return { redirect: "/" } }
Async call
We do an async call to populate the component Users
. We firstly wrap the store.fetchUsers()
with runInAction
. We can chain to display the component. We can also use await when
with a boolean store.users.length>0
to let you wait for changes in observable state.
#routes.js
return await runInAction(()=> store.fetchUsers())
.then(() => import("./Users"))
.then(({default: Users})=> <Users store={store}/>)
or
runInAction(()=>store.fetchUsers())
await when(()=> store.users.length>0)
const {default: Users} = await import("./Users")
return <Users store={store}/>
Search
The search will be simple. There is an input to enter the number that correspond to a user. We build a query string with this number, in the form /users?nb=1. Then the .resolve()
parses the routes looking for /users. The matching action has a function that reads if a query string is present. If yes, extract the number and perform a redirect by returning {redirect: "/users/1"}
. The redirect works then as above.
# routes, path "/users"
const sStg = new URLSearchParams(window.location.search);
const id = sStg.get("nb");
if (id) { return { redirect: `/users/${id}` } }
Conclusion
I wrote 3 posts on my self introduction to Mobx mostly because I did not find code examples not using classes, and also as a reminder. I built a tiny todo app, did a simple POST request from a component and did this tiny navigation app with a GET request. All this used only 4 Mobx verbs: observable
, observer
, action
, runInAction
. Given the magnitude of this work, the usage was fairly simple with the Mobx linting.
As for Universal Router, once you understand how Universal Router works, it lets you build navigation without pain. It also integrates easily React and interacts very easily with Mobx. These libraries also greatly lowered the number of lines of this little app compared to a pure React + React Router solution, without Redux ;)
Top comments (0)