Reinventing the routing in React apps
The router is the backbone of your application. Trust me, that's how it is and I can prove it. One of the fundamental ideas in Web is the URL (Uniform Resource Locator). Or the way of how we very often call it - "web address". No mather what you are building there is a web address which identifies your resource. The router is the front office that accepts the request to particular URL and wires it to a logic. Logic for generating HTML or JSON response or something else. So, the router is very important part of your system.
At the end of last year I rewrote one of my favorite projects - a vanilla JS router called Navigo. I was procrastinating this work for years. Finally I did it in TypeScript and wrote 100+ unit tests along the way. This refactoring opens the door for new features and more importantly, for integrating the library into other places. Like React for example.
โฐ Don't have time to go through the whole article? Go to these three sections to see the most interesting bits: pre-route actions, transitions, easier navigation.
A quick glance at Navigo
I always imagine the routing as a simple mapping between URL and a function. Or in other words, the router should be the orchestrating layer that wires URL to a controller logic. This is how Navigo started and this is how it is going today:
function handler() {
// do something
}
const router = new Navigo('/app');
router
.on('/company/about/team', handler)
.resolve();
Let's say that we open /app/company/about/team
in a browser. We will get the handler
function executed.
Later we should be able to navigate between routes. In Navigo this happens either by calling navigate
method of the router
router.navigate('/company/careers'); // results in /app/company/careers
or by adding data-navigo
attribute to a HTML link:
<a href="/company/careers" data-navigo>Careers</a>
And that's it. This is what the library does. There are some other methods but the API above is what you will probably need.
The standard
I think the dominant solution for routing in React ecosystem is react-router. A solid project that is battle tested by many. It comes with its own problems but that is what most people use. The API is mainly declarative. In the latest version 5 though we have some hooks that help us go out of the components-only model.
There are two main ways to implement routing with react-router. The first one is the well known combination between <Switch>
and <Route>
components:
<Switch>
<Route path="/blog/:slug">
<Blog />
</Route>
<Route exact path="/">
<HomePage />
</Route>
</Switch>
The <Switch>
makes sure that only one of the <Route>
s inside matches. Based on the path
prop we get either <Blog>
or <HomePage>
rendered. <Route>
component has a couple of different APIs lie component
or render
prop but the idea is always the same - we have a path and if that path matches we get our component rendered.
The second approach is to always render our component and use useRouteMatch
to determine if we have a match:
function Foo() {
const match = useRouteMatch("/foo");
if (match) {
return <p>Foo</p>;
}
return null;
}
// somewhere we do
<Foo />
As you can see, an imperative API exists. We manually implement the logic that makes the decision for rendering or not.
So, the bottom line is that we are mapping a path to a React component. If the path matches the browser's URL we get our component rendered. If the URL changes then our component gets unmounted (or it gets re-rendered if we use useRouteMatch
hook).
Overall react-router is a good solution. I like it and I use it almost every day but ... (you know there is always "but").
My "reinventing the wheel" version
During the years Navigo became quit rich on features. Some of those features I miss in my daily work with react-router. So this is how I started navigo-react. It's a router for React driven by Navigo package.
In terms of components, I pretty much mimicked the API of react-router. There are <Switch>
, <Route>
and <Redirect>
components. In addition the library provides <NotFound>
and <Base>
where the latter sets the base path of the app. Similarly to basename
prop of react-router's <BrowserRouter>
(or <HashRouter>
) component. Here is a basic example:
import { Switch, Route } from "navigo-react";
export default function App() {
return (
<>
<nav>
<a href="/" data-navigo>Home</a>
<a href="/package" data-navigo>Package</a>
</nav>
<Switch>
<Route path="/package">
Package info
</Route>
<Route path="/">
NavigoReact is a router for React applications based on Navigo project.
</Route>
</Switch>
</>
);
}
Notice that there is no <Link>
component. Navigo looks for tags that have data-navigo
attribute and wires them to the route's navigate
method. Which also mean that we can type onClick={ () => getRouter().navigate('/foo/bar') }
to change the current path.
In terms of hooks there is only one really - useNavigo
. It gives us access to the matched URL and Navigo context data.
Let's say that we have /user/xxx?action=save
in the address bar of the browser. The <User>
component below will be rendered and the output will be save user with id xxx
.
import { useNavigo } from "navigo-react";
function User() {
const { match } = useNavigo();
return (
<p>
{match.params.action} user with id {match.data.id}
</p>
);
}
export default function App() {
return (
<>
<Route path="/user/:id">
<User />
</Route>
</>
);
}
And this is not something new. In fact react-router works the same way. I focused on three other features that are missing or tricky to implement: pre-route actions, transitions and navigating to other routes.
Pre-route actions
What I mean by pre-route actions is all this work that we need to do before a route gets resolved. This could be changing state, fetching data, redirecting or even blocking the route. The way of how this is solved in Navigo is through the so called router hooks. And because in React the words hooks means something else I decided to call them lifecycle methods. Because that is what they are. They hook to the lifecycle of the route resolving. And this is what I miss the most in react-router. The ability to pause or block the router logic based on certain conditions. We do have redirects (auth) and preventing transitions examples but I feel that this logic should live outside my components. Especially if I want to apply it to multiple routes. Here is a quick snippet of how the auth redirect may be implemented with navigo-react:
import { Route, Switch, getRouter } from "navigo-react";
export default function App() {
const [isLoggedIn, setLoggedInFlag] = useState(false);
const router = getRouter();
const protect = ({ done, match }) => {
if (isLoggedIn) {
done();
} else {
router.navigate(`/login?redirectTo=${match.url}`);
}
};
useEffect(() => {
if (isLoggedIn) {
router.navigate(router.getCurrentLocation().params.redirectTo);
}
}, [isLoggedIn]);
return (
<>
<nav>
<a href="/apples" data-navigo>
Apples
</a>
<a href="/bananas" data-navigo>
Bananas
</a>
</nav>
<section>
<Switch>
<Route path="/apples" before={protect}>
<Apples />
</Route>
<Route path="/bananas" before={protect}>
<Bananas />
</Route>
<Route path="/login">
<button onClick={() => setLoggedInFlag(true)}>sign in</button>
</Route>
</Switch>
</section>
</>
);
}
function Apples() {
return "๐๐๐";
}
function Bananas() {
return "๐๐๐";
}
(Live demo here codesandbox.io/s/navigo-protect-routes-om5vs)
Focus on the protect
function. That is a Navigo lifecycle method. The main role of the done
function is to signal that the routing should continue. We could effectively pause the router delaying its internal logic. In this particular example the user is not logged in initially so we have to redirect him/her to the /login
page. We use that match
object that represents the matched route to understand where the user should come back after successful sign in process. Later when the user is authorized we read the redirectTo
GET parameter and navigate
out to the right place. The protect
function runs again but this time isLoggedIn
is true
and we can proceed with changing the URL and rendering the children of the matched <Route>
component.
By following the same approach we can fetch data that is needed for particular Route. This time we will use the useNavigo
hook. It will give us data passed from within the lifecycle method. Check out the following:
import { Route, useNavigo } from "navigo-react";
function Cat() {
const { myCat } = useNavigo();
if (myCat === null) {
return <p>loading ...</p>;
}
return <img src={myCat} width="200" />;
}
export default function App() {
const getCat = async ({ done, render }) => {
render({ myCat: null });
const res = await (
await fetch("https://api.thecatapi.com/v1/images/search")
).json();
render({ myCat: res[0].url });
done();
};
return (
<>
<nav>
<a href="/cat" data-navigo>Show me a cat</a>
</nav>
<section>
<Route path="/cat" before={getCat}>
<Cat />
</Route>
</section>
</>
);
}
(Live demo here codesandbox.io/s/navigo-react-data-fetching-46jc5)
This example demonstrates the usage of the render
function passed to the lifecycle method. This is de-facto my solution for notifying the rest of the world about what is going on with the routing. We call it with some data and that data becomes reachable via the useNavigo
hook. We are basically saying "render the children of that <Route>
but attach this to the Navigo context". In our case, the myCat
field is used to determine what should be rendered.
Transitions
This is a huge problem in React in general. We all know the pain of doing animations when mounting or unmounting components. Especially when we transition from one page to another. With Navigo that task becomes a bit easier.
import { Route, Switch, useNavigo } from "navigo-react";
const delay = (time) => new Promise((done) => setTimeout(done, time));
const leaveHook = async ({ render, done }) => {
render({ leaving: true });
await delay(900);
done();
};
export default function App() {
return (
<>
<Switch>
<Route path="/card-two" leave={leaveHook}>
<Card bgColor="#254c6a">
<a href="/" data-navigo>To card 1</a>
</Card>
</Route>
<Route path="/" leave={leaveHook}>
<Card bgColor="#1f431f">
<a href="/card-two" data-navigo>To card 2</a>
</Card>
</Route>
</Switch>
</>
);
}
function Card({ children, bgColor }) {
const { leaving } = useNavigo();
const animation = `${leaving ? "out" : "in"} 1000ms cubic-bezier(1, -0.28, 0.28, 1.49)`;
return (
<div
className={`card ${leaving ? "leaving" : ""}`}
style={{ backgroundColor: bgColor, animation }}
>
<p>{children}</p>
</div>
);
}
(Live demo here codesandbox.io/s/navigo-handling-transitions-2-ls977)
The animation in this case is done via vanilla CSS but the same example could be easily implemented with styled-components for example. The idea is that we define a leave
lifecycle method and we render
our current component with a flag leaving: true
. This helps us to delay the actual page transitions till an animation is performed. And the result is the following:
Easier navigation
Something else which I find a bit difficult is to navigate to different routes. In the latest version of react-router we can directly access the history object and push our new URL there. This helps a lot because previously the router was all component based. I like this imperative API and so I did something similar in my project:
import { getRouter } from 'navigo-react';
// somewhere in React component
useEffect(() => {
if (test) {
getRouter().navigate('/login?redirectTo=my-page');
}
}, [])
That's cool but not good enough. Very often we have complex URLs that require combination of static parts and dynamic ones. Like for example /user/:id
and we want to generate /user/xxx
. The Navigo's <Route>
component has a name
prop that is specifically designed for this purpose:
import { getRouter, Route } from "navigo-react";
export default function App() {
return (
<>
<button
onClick={() => {
getRouter().navigateByName("my-user", { id: "xxx" });
}}
>
Click me
</button>
<Route path="/user/:id" name="my-user">
I'm a user
</Route>
</>
);
}
(Live demo here codesandbox.io/s/navigo-react-named-routes-0h2bh)
Final words
I'm really curious about this project of mine navigo-react. I know that it's not battle tested and has 0 adoption but I'll appreciate if you try it and let me know what you think.
By the way its parent Navigo has approx 25K downloads per month. So the base kind of proved its usability.