DEV Community

NDREAN
NDREAN

Posted on • Updated on

Mobx with Universal Router with functional React components

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 value store to the Universal Router object context,
  • 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 the store and observer on components who uses the store. Calls to the store attributes may need the "autorun" form of Mobx runInAction, 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 debug configure from Mobx.

New objects

We will build two plain Javascript objects:

  • store: the global state object - proxied by the observable 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 with action.

  • routes: it is an array of "path" objects that will be parsed by Universal Router. Each contains at least 2 or 3 keys, path and action or children (when the routes are nested). Note that these are UR keywords. They are in the form:

{ path: "/", action: ()=> {...}}
{path: "/", children: [{...},{...}]
Enter fullscreen mode Exit fullscreen mode

How is routing like with U.R.?

We build a routesarray 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"
  ]
]
Enter fullscreen mode Exit fullscreen mode

The action can be asynchronous, just write:

async action (context){ await..}
Enter fullscreen mode Exit fullscreen mode

This routes array is the input of the object:

new UniversalRouter(routes)
Enter fullscreen mode Exit fullscreen mode

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" }}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 = "")
  ),
})
Enter fullscreen mode Exit fullscreen mode

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"),
  },
]
Enter fullscreen mode Exit fullscreen mode

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: "/" } }
Enter fullscreen mode Exit fullscreen mode

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}/>
Enter fullscreen mode Exit fullscreen mode

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}` } }
Enter fullscreen mode Exit fullscreen mode

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)