Routes as State in React

Jun 29

I’ve been using React Router for a while now in a project and was running up against some frustration getting access to information that comes through the URL. I’m using Redux and React Redux for state management and Reselect for getting useful bits of information out of the state. The problem is that when I need to grab some information from the state based on the current route, it can be really tricky to get all of the information I need in the right places. After a good deal of head scratching and soul searching, I realized that for my purposes, the routing information belongs in the state, not encoded implicitly in the components that are rendered.

The Lay of the Land

So I’m working on yet another todo list application. By default it shows you the next ordered task from your list, one at a time. When you select a tag, it should update the URL based on a url friendly slug for that tag (e.g.: /at-home), as well as showing the next task for that specific tag. Starting out, for this workflow, I was using React Router’s Link component and had a simple onClick handler that set the selected tag in the redux store:

1
2
3
4
5
6
7
8
9
function TagLink(props) {
  const { updateTagMeta, slug, name, id } = props;

  return (
    <Link to={`/${slug}`} onClick={() => updateTagMeta({ selectedTagId: id })}>
      {name}
    </Link>
  );
}

Routes looked something like:

1
2
3
4
5
6
7
8
9
10
<Provider store={appStore}>
  <BrowserRouter>
    <Switch>
      <Route exact path='/' component={TaskContainer} />
      <Route path='/sessions/new' component={SessionsNew} />
      <Route path='/tasks' component={TaskList} />
      <Route path='/:slug' component={TaskContainer} />
    </Switch>
  </BrowserRouter>
</Provider>

This all worked well and fine, as long as the user got to a tag by clicking on it. However, if the user used the URL to navigate to a tag (e.g.: by visiting /at-home) things didn’t work. There was no selectedTagId in the store so it ended up showing the next task from all of them. I had to find a way to get the currently selected tag into the state from the get go. When a user clicked the button, I set the selected tag id and also the slug in the URL, but when they went directly to that URL, I needed a way to derive the tag id from the slug already in the URL.

I considered a few options here. The first was to find a way to make use of the slug that was being parsed out by the Route component. It passed the matched URL elements down to the sub-components, so I could have added some lifecycle hooks to check for an updated slug and update the store accordingly. This didn’t feel quite right to me, though. I don’t like my components being too smart, and in this case it felt weird to have my sub-components doing the work of compensating for missing state that really ought to already be in the store. I briefly looked at React Router Redux, but the only thing it provides you is the entire route path, not the matched elements from the path, so with or without it, I’m left with the option of parsing the path myself, duplicating the work that is already being handled by the Route component. I could have written my own wrapper component, but when I am starting to write as much code to work around a library as it would take to roll my own, I start to think it’s time to build something more specific to my needs.

My Approach

In addition to the issue above, I’ve also had another nagging concern. I don’t like how tightly coupled the logic in my app is tied to the path in the URL. Much like one would fetch and persist data to a server, but store it locally in, say, a Redux store, it seems to me like we ought to do an initial fetch of the data from the URL, normalize it in a reasonable format for our client side, and hold onto it in our store. Likewise, when we update the information, we persist it to the URL, but continue to use our store as the source of data for our components. With all of this in mind, here’s what I came up with.

Routes

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import pathToRegexp from 'path-to-regexp';

function compileRoutes(routes) {
  return routes.map((route) => {
    const regexp = pathToRegexp(route.path);

    return {
      ...route,

      match(path) {
        const result = regexp.exec(path);

        if (!result) { return null; }

        const params = {};

        regexp.keys.forEach((key, index) => {
          params[key.name] = result[index + 1];
        });

        return params;
      },

      toPath: pathToRegexp.compile(route.path),
    };
  });
}

const ROUTES = compileRoutes([
  {name: 'root', path: '/'},
  {name: 'sessionsNew', path: '/sessions/new'},
  {name: 'tasks', path: '/tasks'},
  {name: 'tag', path: '/:slug'},
]);

export default ROUTES;

Reducer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import ROUTES from 'src/route/routes';

export default function routeReducer(previousState, action) {
  if (action.type === 'route/SET') {
    const {name, ...params} = action.payload;
    const matchingRoute = ROUTES.find((route) => route.name === name);

    window.history.pushState(null, null, matchingRoute.toPath(params));

    return {name, params};
  } else if (action.type === 'route/INIT') {
    let params;

    const matchingRoute = ROUTES.find((route) => {
      params = route.match(window.location.pathname);

      return Boolean(params);
    });

    return {name: matchingRoute.name, params};
  }
};

Router Container

1
2
3
4
5
6
7
8
9
import {connect} from 'react-redux';

import Router from 'src/route/components/router';

function mapStateToProps(state) {
  return {route: state.route};
}

export default connect(mapStateToProps)(Router);

Router Component

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import React from 'react';

import SessionsNew from 'src/session/components/new';
import TaskContainer from 'src/task/containers/item';
import TaskList from 'src/task/containers/list';

const ROUTE_NAME_TO_COMPONENT_MAP = {
  root: TaskContainer,
  sessionsNew: SessionsNew,
  tasks: TaskList,
  tag: TaskContainer,
};

export default function Router(props) {
  const Component = ROUTE_NAME_TO_COMPONENT_MAP[props.route.name];

  return <Component />;
};

Link Container

1
2
3
4
5
6
import {connect} from 'react-redux';

import Link from 'src/_common/components/link';
import {setRoute} from 'src/route/action_creators';

export default connect(null, {setRoute})(Link);

Link Component

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import React from 'react';

import ROUTES from 'src/route/routes';

export default class Link extends React.Component {
  constructor(props) {
    super(props);
    this.navigate = this.navigate.bind(this);
  }

  navigate(event) {
    event.preventDefault();

    this.props.setRoute({name: this.props.to, ...this.props.params});
  }

  path() {
    const matchingRoute = ROUTES.find((route) => route.name === this.props.to);

    return matchingRoute.toPath(this.props.params);
  }

  render() {
    const {className, children} = this.props;

    return (
      <a href={this.path()} className={className} onClick={this.navigate}>
        {children}
      </a>
    );
  }
};

And the Tag Link from before becomes…

1
2
3
4
5
6
7
8
9
function TagLink(props) {
  const { slug, name } = props;

  return (
    <Link to='tag' params={{slug}}>
      {name}
    </Link>
  );
}

Epilogue

Et voila! The routing state is now in the store and accessible wherever we might need a bit of info about what is going on. I’ve also made it so that the routes are named. Some additional considerations:


Comments

comments powered by Disqus