UPDATE: This post was previously titled "Declarative React Router". With some reflection, this post is not really about declarative code, it's more about creating a particular kind of abstraction. Oops!

I like React Router, but I find that naively copying the programming style from their example docs leads to fragile, repetetive code. In this post, and a few follow-ups, I'll cover how I like to abstract my routing logic into a data structure, and why this style is useful.

Here's the situation: you have a React single page app with a bunch of views and routes. For example, your app might have the following views:

home

about

user profile

login

register

... etc.

The challenge is that you need to implement this and then stare at it every day for the next 6 months.

First Cut

Here's the most naive way you could throw together this site:

// app.js const App = () => ( < BrowserRouter > < Switch > < Route path = "/home" render = {() => (< div > Home Page </ div >)} /> < Route path = "/about" render = {() => (< div > About Page </ div >)} /> < Route path = "/login" render = {() => (< div > Login </ div >)} /> < Route path = "/register" render = {() => (< div > Register </ div >)} /> < Route path = "/user/:userId/profile" render = {({ match : { params : { userId } } }) => ( < div > Your Profile ( { userId } ) </ div > )} /> </ Switch > </ BrowserRouter > )

This is fine for getting started, but once your views and routes grow more complicated, then this will become completely unworkable. Why is that?

I think this put-everything-in-one-place strategy eventually fails because of entangled concerns. Consider that the above code is responsible for:

defining all the site's paths

mapping the paths to views

describing how each view is rendered

dealing with React Router implementation details

describing how path parameters get passed to each view

It's just too much stuff going on in one place. The computer can handle it fine, but your brain is going to struggle. There's a lot of repetition and syntactic noise, and as a result you can't easily understand what's going on by inspecting the code.

Second Attempt

We can improve this code by just pulling the view functions out of the routes:

// app.js const App = () => ( < BrowserRouter > < Switch > < Route path = "/home" render = { HomeView } /> < Route path = "/about" render = { AboutView } /> < Route path = "/login" render = { LoginView } /> < Route path = "/register" render = { RegisterView } /> < Route path = "/user/:userId/profile" render = { ProfileView } /> </ Switch > </ BrowserRouter > ) // views.js const HomeView = () => (< div > Home Page </ div >) const AboutView = () => (< div > About Page </ div >) const LoginView = () => (< div > Login </ div >) const RegisterView = () => (< div > Register </ div >) const ProfileView = ({ match : { params : { userId } } }) => ( < div > Your Profile ( { userId } ) </ div > )

I think this code is easier to read than before, but we can do better. I don't see why we have to stare at all of this JSX boilerplate when all we typically want to do is figure out how routes map to views. There are a lot of lazy angle brackets in this code, sitting around, junking up the place, not pulling their weight.

What Do I Want?

When you're programming, you start with some intent in mind, and then you add all the crap that you need to make it happen.

Sometimes the best approach is to write your intent as a data structure, and then write the code that turns that data into what you need. In this case what we really care about is our site's paths and how they relate to our views. Our data structure looks like this:

const ROUTES = [ { path : '/home' , view : HomeView }, { path : '/about' , view : AboutView }, { path : '/login' , view : LoginView }, { path : '/register' , view : RegisterView }, { path : '/user/:userId/profile' , view : ProfileView }, // Add your next view here ]

I like this approach because you don't need to know how to solve the problem in advance. You are thinking in terms of what you really want to achieve, not how you're going to do it. You are not limited by what you know how to type into your editor right now - you can take a step back and think about it. This process of posing a problem for yourself and then solving it can be quite fun and fruitful.

Third Time's the Charm

Turning your route data into React Router code is straightforward:

// app.js const App = () => ( < BrowserRouter > < Routes /> </ BrowserRouter > ) // routes.js const ROUTES = [ { path : '/home' , view : HomeView }, { path : '/about' , view : AboutView }, { path : '/login' , view : LoginView }, { path : '/register' , view : RegisterView }, { path : '/user/:userId/profile' , view : ProfileView }, ] const Routes = () => ( < Switch > { ROUTES . map (({ path , view }) => ( < Route key = { path } path = { path } render = { view } /> ))} </ Switch > )

Now we have separated our concerns:

the views care about how things look

ROUTES cares about paths and how they map to views

cares about paths and how they map to views Routes cares about React Router specifics

cares about React Router specifics App cares about gluing everything together

Now we can make changes to our code with more speed and confidence.

Quick! Change Everything!

Your views crash sometimes. No big deal, it happens. Unfortunately when this happens it crashes the whole React app and the user sees nothing but a blank screen. You can fix this by wrapping all your views in an error boundary.

Using our naive implementation, we would need to add a new wrapper for each view, adding ten new lines of code, whereas with our last solution we only need to add two:

const Routes = () => ( < Switch > { ROUTES . map (({ path , view }) => ( < ErrorBoundary key = { path }> < Route path = { path } render = { view } /> </ ErrorBoundary > ))} </ Switch > )

Fewer lines of code means fewer bugs. We can't accidentally forget to wrap one route using this implementation. Either we get them all or we get none of them. If there's a bug in Routes , it'll crash everything, making it easy to detect and fix.

Single Source of Truth

An unexpected benefit of extracting our code into a data structure is that we now have a single source of truth that describes the structure of our website. For example, say that you want to add a menu to your site. You can use your route data to generate the menu links:

// routes.js const ROUTES = [ { name : 'Home' , path : '/home' , view : HomeView }, { name : 'About' , path : '/about' , view : AboutView }, { name : 'Login' , path : '/login' , view : LoginView }, { name : 'Register' , path : '/register' , view : RegisterView }, { name : 'Profile' , path : '/user/:userId/profile' , view : ProfileView }, ] // menu.js const NavMenu = () => ( < div > { ROUTES . map (({ name , path , view }) => ( < Link key = { path } to = { path }>{ name }</ Link > ))} </ div > )

Importantly, any change to the route data will update both the menu and the routing - there's no way they can get out of sync, because they both rely on the same data.

Conclusion

Expressing your intent as a data structure can slim down your code base, reduce bugs and make changes more transparent. These results are achieved by separating the what from the how. I use this technique a lot, but only after observing a recurring problem in my code. As Sandi Metz said: duplication is far cheaper than the wrong abstraction, so be careful to ensure that you're familiar with the problem that you're solving before you try and apply this technique.