Let's see how a custom routing solution under 100 lines of code may look like.
First, you will need to implement the list of application routes in which each route can be
represented as an object with properties of path
(a parametrized URL path string), action
(a function), and optionally children
(a list of sub-routes, each of which is a route object).
The action
function returns anything - a string, a React component, etc. For example:
export default [
{
path: '/tasks',
action() {
const resp = await fetch('/api/tasks');
const data = await resp.json();
return data && {
title: `To-do (${data.length})`,
component: <TodoList {...data} />
};
}
},
{
path: '/tasks/:id',
action({ params }) {
const resp = await fetch(`/api/tasks/${params.id}`);
const data = await resp.json();
return data && {
title: data.title,
component: <TodoItem {...data} />
};
}
}
];
Next, implement a URL Matcher function that will be responsible for matching a parametrized
path string to the actual URL. For example, calling matchURI('/tasks/:id', '/tasks/123')
must
return { id: '123' }
while calling matchURI('/tasks/:id', '/foo')
must return null
.
Fortunately, there is a great library called path-to-regexp
that makes this task very easy. Here is how a URL matcher function may look like:
import toRegExp from 'path-to-regexp';
function matchURI(path, uri) {
const keys = [];
const pattern = toRegExp(path, keys); // TODO: Use caching
const match = pattern.exec(uri);
if (!match) return null;
const params = Object.create(null);
for (let i = 1; i < match.length; i++) {
params[keys[i - 1].name] = match[i] !== undefined ? match[i] : undefined;
}
return params;
}
Finally, implement a Route Resolver function that given a list of routes and a URL/context
should find the first route matching the provided URL string, execute its action method, and if the
action method returns anything other than null
or undefined
return that to the caller.
Otherwise, it should continue iterating over the remaining routes. If none of the routes match to the
provided URL string, it should throw an exception (Not found). Here is how this function may look like:
import toRegExp from 'path-to-regexp';
function matchURI(path, uri) { ... } // See above
async function resolve(routes, context) {
for (const route of routes) {
const uri = context.error ? '/error' : context.pathname;
const params = matchURI(route.path, uri);
if (!params) continue;
const result = await route.action({ ...context, params });
if (result) return result;
}
const error = new Error('Not found');
error.status = 404;
throw error;
}
export default { resolve };
That's it! Here is a usage example:
import router from './router';
import routes from './routes';
router.resolve(routes, { pathname: '/tasks' }).then(result => {
console.log(result);
// => { title: 'To-do', component: <TodoList .../> }
});
While you can use this as it is on the server, in a browser environment it must be combined with a
client-side navigation solution. You can use history
npm module to handles this task for you. It is the same library used in React Router, sort of a
wrapper over HTML5 History API that
handles all the tricky browser compatibility issues related to client-side navigation.
First, create src/history.js
file that will initialize a new instance of the history
module
and export is as a singleton:
import createHistory from 'history/lib/createBrowserHistory';
import useQueries from 'history/lib/useQueries';
export default useQueries(createHistory)();
Then plug it in, in your client-side bootstrap code as follows:
import ReactDOM from 'react-dom';
import history from './history';
import router from './router';
import routes from './routes';
const container = document.getElementById('root');
function renderRouteOutput({ title, component }) {
ReactDOM.render(component, container, () => {
document.title = title;
});
}
function render(location) {
router
.resolve(routes, location)
.then(renderRouteOutput)
.catch(error =>
router.resolve(routes, { ...location, error }).then(renderRouteOutput),
);
}
render(history.getCurrentLocation()); // render the current URL
history.listen(render);
Whenever a new location is pushed into the history
stack, the render()
method will be called,
that itself calls the router's resolve()
method and renders the returned from it React component
into the DOM.
In order to trigger client-side navigation without causing full-page refresh, you need to use
history.push()
method, for example:
import history from '../history';
function transition(event) {
event.preventDefault();
history.push({
pathname: event.currentTarget.pathname,
search: event.currentTarget.search,
});
}
function App() {
return (
<ul>
<li>
<a href="/" onClick={transition}>
Home
</a>
</li>
<li>
<a href="/one" onClick={transition}>
One
</a>
</li>
<li>
<a href="/two" onClick={transition}>
Two
</a>
</li>
</ul>
);
}
Though, it is a common practice to extract that transitioning functionality into a stand-alone
(Link
) component that can be used as follows:
<Link to="/tasks/123">View Task #123</Link>
React Starter Kit (RSK) uses Universal Router npm
module that is built around the same concepts demonstrated earlier with the major differences that
it supports nested routes and provides you with the helper Link
React component. It can be seen as
a lightweight more flexible alternative to React Router.
- It has simple code with minimum dependencies (just
path-to-regexp
andbabel-runtime
) - It can be used with any JavaScript framework such as React, Vue.js etc
- It uses the same middleware approach used in Express and Koa, making it easy to learn
- It uses the exact same API and implementation to be used in both Node.js and browser environments
The Getting Started page has a few examples how to use it.
- You might not need React Router by Konstantin Tarkus