Router5 integration with mobx and react.
Working Example: react-mobx-router5-example
This package represents a routing alternative to react-router.
This is especially useful if you already use mobx and mobx-react in your react project.
To make this work in your project you should use router5 as routing library (independent from react) and the mobx-router5 plugin which exposes the router5's states as mobx observable variables.
The React Components exported by this package uses mobx-router5 as the source of truth.
They observe the mobx-router5
observables and react when they change.
- react >= 16.0.0
- mobx >= 5.0.0
- mobx-react >= 5.2.0 - In order to be compatible with MobX > 5.0.0
- router5 >= 6.1.0 - Version 7 not yet supported
- mobx-router5 >= 4.3.0 - In order to be compatible with MobX > 5.0.0
These are considered peerDependencies
that means they should exist in your installation, you should install them yourself to make this plugin work.
The package won't install them as dependencies.
Notice Mobx@5 introduced breaking changes, please follow the migration guide
npm install react-mobx-router5
In you application entry point you should configure and instantiate a new router
and a mobx-router5 routerStore
as
described in the mobx-router5 documentation.
The routerStore
instance is important as it exposes the router5
state as mobx observables. So the routerStore is indeed the source of truth for our components.
After the instantiation of the store we need to pass it to the components using the mobx-react
Provider component.
Internally the components exported by this package will use @inject
to grab the routerStore
.
An example would make this more clear:
//stores.js (mobx stores)
import tabStore from './TabStore';
import userStore from './UserStore';
import {RouterStore} from 'mobx-router5';
// Instantiate it directly or extend the class as you wish before invoking new
const routerStore = new RouterStore();
export {
tabStore,
userStore,
routerStore
};
//create-router5.js
import {createRouter} from 'router5';
import loggerPlugin from 'router5/plugins/logger';
import browserPlugin from 'router5/plugins/browser';
import {mobxPlugin} from 'mobx-router5';
import routes from './routes';
import {routerStore} from './stores';
export default function configureRouter(useLoggerPlugin = false) {
const router = createRouter(routes, {defaultRoute: 'home'})
.usePlugin(mobxPlugin(routerStore)) // Important: pass the store to the plugin!
.usePlugin(browserPlugin({useHash: true}));
if (useLoggerPlugin) {
router.usePlugin(loggerPlugin) ;
}
return router;
}
//app.js
import React from 'react';
import ReactDOM from 'react-dom';
import {Provider} from 'mobx-react';
import Layout from './components/Layout'
import * as stores from './stores'; //mobx stores
import createRouter from './create-router5';
const router = createRouter(true);
// Provider will add all the mobx stores (including the routerStore) in context.
const wrappedApp = (
<Provider {...stores } >
<Layout/>
</Provider>
);
// Renders the entire app when the router starts
router.start((err, state) => {
ReactDOM.render(
wrappedApp,
document.getElementById('app')
);
});
From now on you can use all the components and HOC exported by this package without further steps.
The components will always be in sync with the routerStore
internal observables and react when they will change.
Components for Routing and View Selection
- routeNode: HOC for wrapping the 'route nodes' of your app
- getComponent: Helper function to select what component to render from routes configuration
- RouteView: Component to automatically select and render a component retrieved from the routes configuration
Components for Navigation and Routes Injections
- withRoute: HOC to re-render any component on any route change and inject router's observables
- BaseLink: Component to generate simple
a
element not aware of route's change - Link: Component resulting from
BaseLink
andwithRoute
composed together - withLink: HOC for creating custom wrappers around a
<BaseLink/>
component - NavLink: Component resulting from
li
element andwithLink
composed together
Quoting the router5 documentation:
On a route change, you only need to re-render a portion of your app.
This is basically what routeNode
is for: by wrapping a node component (a component associated with a route having children routes) we are telling to re-render
only a portion of our app when there is a specific route change. This is probably the most important component of this package.
Signature
routeNode(nodeName, storeName='routerStore')(RouteComponent)
Params
nodeName
: the name of route to associate to the component (''
if root node)storeName
(optional, default:'routerStore'
): the RouterStore name if it differs from the defaultRouteComponent
: the component to wrap
Return
The routeNode
function returns another function routeNodeWrapper(RouteComponent)
that in turn returns a RouteNode
component: the actual HOC.
The final usage would be:
const RouteNodeInstance = routeNode(nodeName)(RouteComponent)
;
The newly created RouteNodeInstance
HOC is a wrapper around RouteComponent
and will:
- forward all the props received to the wrapped
RouteComponent
- inject the
route
(observable),plainRoute
(non-observable) androuterStore
props to theRouteComponent
- trigger a re-rendering of itself and of the wrapped
RouteComponent
only when the nodeName is the correct intersection node for the current route transition (see understanding router5 and the example below).
Usage
Given for example a route name 'users'
associated with a component UsersComp
having children routes users.list
and users.detail
then the route 'users'
should be considered a route node for the application.
Its associated component UsersComp
should be the one responsible for selecting and re-rendering the components associated with its children routes.
const UsersCompNode = routeNode('users')(UsersComp)
;
Example
In the following example when navigating from 'users.list'
-> 'users.detail'
then 'users'
is the intersection node.
Wrapping the UserComp
with RouteNode
ensures that during this transition the UserComp
will re-render
and so it will be able to determine which component to show (associated to one of its sub-routes).
If then we navigate from 'users.list'
-> 'home'
then the intersection node is ''
(the root node) and so UsersComp
will not re-render.
In this case it's the root node responsibility (for example a Main
component wrapped with routeNode
) to re-render.
Note
The logic on how to select and render the correct sub-component is up to you, in the example it is used a simple switch.
See the Rendering Route Views section below for alternative implementations.
import React from 'react';
import { routeNode } from 'react-mobx-router5';
import { UserView, UserList, NotFound } from './components';
function UsersComp(props) {
// These are injected by routeNode HOC
const { routerStore, route, plainRoute } = props;
switch (route.name) {
case 'users.detail':
return <UserDetail/>;
case 'users.view':
return <UserView/>;
default:
return <NotFound/>;
};
}
export default routeNode('users')(UsersComp);
At this point only the components wrapped with routeNode
are associated with some of the app routes, so what about all the other components and routes?
[...] rather than the router updating the view, it is up to the view to listen / bind / subscribe to route changes in order to update itself.
[...] The router is unaware of your view and you need to bind your view to your router's state updates.
In the above example the sub-components of a component wrapped with routeNode
are selected with a simple switch statement. This is a possible implementation.
In this section I present two (opinionated) personal implementations:
getComponent
function helperRouteView
Component
Be warned:
- these are not the only ones, you are free to implement your own
- hot reloading might not work
- these implementations are optional, you can use this package without them (and use a simple switch for example)
For both solutions to work we need to use the router5' Nested arrays of routes config introducing an additional component
field for associating a component with a route.
Example
//routes.js
import {Home} from './components/Home';
import {Index} from './components/Index';
import {Login} from './components/Login';
import Sections from './components/Nodes/Sections';
import Subsections from './components/Nodes/Subsections';
export default [
// children of the root routeNode ''
{ name: 'home', path: '/', component: Home}, // Notice the extra `component` field
{ name: 'login', path: '/login', component: Login},
{ name: 'index', path: '/index/:id', component: Index},
{ name: 'section', path: '/section', component: Sections, children: [
// children of 'section' routeNode
{ name: 'home', path: '/home', component: Home },
{ name: 'login', path: '/login', component: Login },
{ name: 'index', path: '/index/:id', component: Index },
{ name: 'subsection', path: '/subsection', component: Subsections, children: [
// children of 'section.subsection' routeNode
{ name: 'home', path: '/home', component: Home },
{ name: 'login', path: '/login', component: Login },
{ name: 'index', path: '/index/:id', component: Index },
]}
]}
];
Notice that the component associated with a route node (the ones having children, for example Sections
) should already be wrapped with routeNode
. In other words the Sections
component should be exported like this:
export default routeNode('section')(Sections);
Also notice that by associating a route with a component might apparently break the router5's principle
The router is unaware of your view
but as I will show this isn't true because the component
field is not used by the router but by our views!
When using the above routes configuration this helper is used for selecting the correct component to render for a given route
and a routeNodeName
.
This represents an alternative to the switch statement.
Signature
getComponent(route, routeNodeName, routesConfig)
Params
route
: either therouterStore.route
object or the route name as a string. Usually it's the currently active routerouteNodeName
: the name of the route associated with the React component from where to re-render (Node Component)routesConfig
: nested routes configuration array (with the extracomponent
as shown above)
Return
It returns a React.Component
: the component to be rendered extracted from the routes configuration.
Example
//Main.jsx: the root routeNode ('')
import React, {createElement} from "react";
import {routeNode, getComponent} from "react-mobx-router5";
import routes from "../../routes";
class Main extends React.Component {
render(){
// injected by routeNode HOC
const { routerStore, route, plainRoute } = this.props;
// This will extract the correct component amongst the children of '' for the current route
// Notice that the ComponentToRender could also be another "node component", that is associated with another routeNode, for example `Section`
const ComponentToRender = getComponent(route, '', routes);
// Passing the route prop will ensure that the ComponentToRender will be re-rendered for each new route
return createElement(ComponentToRender, {route: route});
}
}
// higher-order component to wrap a route node component.
export default routeNode('')(Main);
//Section.jsx: the `section` routeNode
import React, {createElement} from "react";
import {routeNode, getComponent} from "react-mobx-router5";
import routes from "../../routes";
class Section extends React.Component {
render(){
// injected by routeNode HOC
const { route, routerStore, plainRoute } = this.props;
// in this case I use the route name (stirng)
const ComponentToRender = getComponent(route.name, 'section', routes);
return createElement(ComponentToRender, {route: route});
}
}
export default routeNode('section')(Section);
The getComponent
solution introduces some repetition, that is, you always need to grab the component to render and then render it:
const ComponentToRender = getComponent(route, 'section', routes);
return createElement(ComponentToRender, {route: route});
The RouteView
component does these two operations for you.
Props
All props not listed below will be passed trough to the new generated component additionally forwarding the route
prop.
route
: a route object (required). It might be better to pass the non-observableplainRoute
injected byrouteNode
rather thanrouterStore.route
to avoid possible inconsistencies if the subcomponents are observers ofroute
(TODO: This use case needs more study)routeNodeName
: the name of the route for the React component from where to re-render (route node)routes
: nested routes configuration array (with the extracomponent
field for each route)errorMessage
: a string for custom message to display inside anh1
in case of error during the component selection. Optional, default: 'Something went wrong.'errorStyle
: a style object to be applied to theh1
error description. Optional, default: {color: 'rgb(217, 83, 79)'
Notice that the RouteView component is internally wrapped with an Error Boundary Component (introduced in react 16), this ensure that in case of exception while selecting the component to display the entire app won't crash and an error message will be displayed instead. The error message is rendered inside an h1
, it is customizabile using the errorMessage
and errorStyle
props passed to routeView
.
Example
//Main.jsx: the root routeNode ('')
import React, {createElement} from "react";
import {routeNode, RouteView} from "react-mobx-router5";
import routes from "../../routes";
const routeNodeName = '';
class Main extends React.Component {
render(){
const {route, routerStore} = this.props;
return <RouteView
route={route}
routeNodeName={routeNodeName}
routes={routes}
// other props
otherProp='hello'
myOtherProp='bye' />;
}
}
export default routeNode('')(Main);
Notice in the above example that the newly generated component will receive these props: otherProp
, myOtherProp
and the extra route
.
Function that generates an higher-order component to wrap any component that need to re-render on route changes.
Signature
withRoute(BaseComponent, storeName='routerStore')
Params
BaseComponent
: the component to be wrappedstoreName
(optional, default:routerStore
): the RouterStore name if it differs from the default
Return
It returns a ComponentWithRoute
that wraps BaseComponent
.
Usage
const MyCompWithRoute = withRoute(MyComp);
Any component wrapped by this HOC:
- receives all the props passed to the wrapper
- is injected with these extra props coming from mobx-router5:
routerStore
,route
- is injected with these computed extra props:
isActive
andclassName
(see below) - re-renders on any route change
Injected computed props (isActive
and className
)
Some special props passed to the wrapper are used to compute other extra props that will be injected in the wrapped component.
The following props are used to compute a new prop isActive
(bool) injected into the wrapped BaseComponent
:
routeName
(string): name of the route that should be associated with theBaseComponent
routeParams
(obj) default{}
: the route paramsactiveStrict
(bool) defaultfalse
: whether to check ifrouteName
is the active route, or part of the active route
Also if a routeName
prop is passed then an activeClassName
(default 'active') will be added to the className
when the component is isActive
and the newly computed prop className
(string) is injected into the wrapped BaseComponent
:
className
(string) default''
: prop forwardedactiveClassName
(string) default'active'
: the name of the class to apply when the element is active
Example
In the following example the MyComp used within Container will re-render on any route change.
When the current route is home
:
- a className
'hello hyperactive'
will be applied - the prop
isActive
will betrue
// MyComp.jsx
import React from 'react';
import { withRoute } from 'react-mobx-router5';
function MyComp(props) {
// these are injected by withRoute
const { route, isActive, className } = props;
return (
<div className={className}>
I am {isActive: 'active' ? 'inactive' } <br/>
The current route is {route}
</div>
)
}
export default withRoute(MyComp);
// Container.jsx
import React from 'react';
import MyComp from './MyComp';
function Container(props) {
return (
<div>
<MyComp
routeName='home'
className='hello'
activeClassName='hyperactive' />
</div>
)
}
export default Container;
It generates an anchor a
tag with href
computed from props.routeName
.
Note: This component won't re-render on route change.
Props
In order to work it is mandatory to pass at least one of these props to the component:
router={routerInstance}
the router5 instance object.routerStore={routerStore}
the mobx-router5routerStore
. If passed will take precedence overrouter
proponClick={onClickCB}
when passed the navigation will be prevented (the above 2 props become both unnecessary) and theonClickCB
function will be executed instead.
Props needed to compute the correct href
(when passing either router
or routerStore
):
routeName="home"
route to navigate to when the component is clickedrouteParams={routeParamsObj}
optional, default {}routeOptions={routeOptionsObj}
optional, default {}
Example
import React from 'react';
import { BaseLink } from 'react-mobx-router5';
function logMeIn(e) {
e.preventDefault();
console.log('clicked');
}
function Menu(props) {
return (
<nav>
<BaseLink routerStore={props.routerStore} routeName='home' routeOptions={{reload: true}}>Home</Link>
<BaseLink router={props.router} routeName='home'>About</Link>
<BaseLink onClick={logMeIn} >Login</Link>
</nav>
);
}
export default Menu;
The Link
component is BaseLink
and withRoute
composed together.
This means that Link
will re-render on any route change and an 'active' class will be applied to it when
the current route is props.routeName
.
Function that generates a higher-order component to create custom wrappers around a <BaseLink/>
component.
Useful for creating any sort of wrappers that will be aware of route changes, for example for creating navigation menus.
Signature
withLink(LinkWrapper, storeName='routerStore')
Params
LinkWrapper
: the component used to wrap the innerBaseLink
storeName
(optional, default:routerStore
): the RouterStore name if it differs from the default
Return
The function creates a new WithLink
higher-order component that wraps the passed LinkWrapper
.
The LinkWrapper
is in turn a wrapper around the BaseLink
.
This composed element is then passed to withRoute(WithLink)
.
The final returned component would then be a ComponentWithRoute
Props
The final composed component accepts all the props accepted by the WithRoute
component with an extra special
linkClassName
prop.
All props passed to the composed component (including the one injected by withRoute
) will be forwarded to the inner BaseLink
except for className
.
In fact the (computed 'active') className
will be applied to the LinkWrapper
while the extra linkClassName
(unmodified) will be applied to the inner BaseLink
.
Also all the children
of the final composed component will become children of the inner BaseLink
.
Example
const MyLinkWrapper = withLink('div');
- This produces a
div
that wraps aBaseLink
component. Then this result is passed towithRoute
. - The
'active'
className will be applied to thediv
not the onBaseLink
(so not on the generateda
). - If we pass an
linkClassName
then it will become theclassName
of the innerBaseLink
(so of the generateda
)
See the NavLink
component.
The NavLink
component is the li
element and withLink
composed together.
const NavLink = withLink('li');
Example:
import {NavLink} from 'react-mobx-router5'
function MyComponent(props) {
return (
<NavLink
className="hello"
linkClassName="goodbye"
routeName="home">
HOME
</NavLink>
)
}
Will produce something like this (pseudo-code):
<li className={props.className} >
<BaseLink { ...props } className={props.linkClassName} >
{props.children}
</BaseLink>
</li>
That is indeed very similar to what Link
looks like, except this will apply the 'active' className to the li
and the
linkClassName
to the internal BaseLink
(and so to the generated an a
tag)
As recommended by React and
MobX make sure to substitute
process.env.NODE_ENV = "production"
in your build process.
The components are shipped with prop-types checks (prop-types
is dependencies of this package).
If you want to remove them from your build you could use babel-plugin-transform-react-remove-prop-types.
You could for example add this to your babel config:
{
"env": {
"production": {
"plugins": [
"transform-react-remove-prop-types", {
"mode": "wrap"
}
]
}
}
}
or
{
"env": {
"production": {
"plugins": [
["transform-react-remove-prop-types", {
"mode": "remove",
"removeImport": true
"ignoreFilenames": ["node_modules"]
}]
]
}
}
}
Check the doc of the plugin for choosing the correct configuration.
PR, suggestions and help is appreciated, please make sure to read the CONTRIBUTING.md file.
For development a version of node >=8.11 is needed as some dev packages require it (for example semantic-release), see .nvmrc
- The structure and build process of this repo are based on babel-starter-kit
- I've taken the react-router5 package as example for developing this one
- Thanks to egghead.io for the nice tips about open source development on their free course
- Thanks to Olivier Tassinari for the fast fix and suggestion needed by this package
- Special thanks to Thomas Roch for the awesome router5 ecosystem