State-Driven Routing
React, Redux, SelectorsBy Baz on Oct. 24, 2016
See the demo and source on github
Routing is complex.
There are a lot of disparate pieces that come together to give users the ability to navigate a site.
There is the address bar ui element which displays a url string on screen.
There is back/forward functionality accessible through buttons, keyboard shortcuts or other input methods.
There are <a>
links which navigate to new content in various ways.
There is url input through the address bar.
Most people outsource that complexity to 3rd-party libs like react-router (64k minified). They learn and apply the library's dsl, and integrate the custom code into their views. Routing becomes a layer of the app to be managed.
While routing as a whole is complex, the individual pieces are not. Structured tools like react, redux and selectors are more than capable of modeling them cleanly. The key is to address each piece in its right place ♪.
Just another ui element
The address bar is the main visual element. It has two distinct duties: to display a url at the top of the site, and to get new urls from input. Even though they come together in the same element, they interact with different systems of the browser, and can be dealt with in separate parts of the app. This lets us treat the address bar like any other display component. We pass data in as props, and use the component's api to render it on screen - in this case, the history api. The following code running on render keeps the address bar up to date with the latest url:
if (this.props.url !== window.location.pathname + window.location.search) {
window.history.pushState(null, null, this.props.url);
}
app.jsx
We are cheating a little of course. The history call also adds an item in the browser's navigation history, but the simple conditional check ensures that it works as expected. If more logic is needed, there are no limits, as it is all native. Other than that, the address bar's relationship to the app becomes like that of any other visual component. The app dictates the url, rather than the url dictating the app, regardless of what was input in the address bar.
Where do urls come from?
Now that we have the view, we can work on the address bar's url intake functionality.
Whenever the url in the address bar is updated by the user, the app is reloaded.
Capturing that url, is a matter of reading window.location
on app load:
// get url on app load
var url = window.location.pathname + window.location.search;
The app can also get urls from back/forward navigation.
To capture those, regardless of how they originated, we can listen for onpopstate
.
Here is an example index.js
that captures all the urls we may need:
var viewState = {};
// get url on app load
viewState.url = window.location.pathname + window.location.search;
// get url on back/forward
window.onpopstate = function(e) {
viewState.url = window.location.pathname + window.location.search;
};
// render view and url
render(
<App {...viewState} />,
document.getElementById('app')
);
index.js
Let's drive some state
When we receive a url from a page load, or back event, or code, nothing has actually happened yet in the app. The app is still in its default state, or, in the case of client-side navigation, in the last state it was in. It is up to the app to update itself accordingly. Specifically, the app must parse out the relevant pieces of state, from the string representation of state that is the url.
In redux, mutating state is the job of reducers.
We introduce an action called UPDATE_URL
with url-related params, that reducers can respond to.
For example, this reducer uses the url's path to set selectedSection
whenever UPDATE_URL
is dispatched:
import { UPDATE_URL } from '../../site/actions/update-url';
import { HOME } from '../../site/constants/sections';
import { PATHS_SECTIONS } from '../../site/constants/paths';
export default function (selectedSection = HOME, action) {
switch (action.type) {
case UPDATE_URL:
return PATHS[action.path] || HOME;
default:
return selectedSection;
}
}
selected-section.js
Many different reducers can respond to UPDATE_URL
, allowing for complex url schemes, without complicated code.
Once all reducers are in place, an app can update its entire state from any url by invoking UPDATE_URL
.
Using an action creator updateURL for convenience, we can rewrite index.js
like this:
// update url on app load
updateURL(window.location.pathname + window.location.search);
// update url on back/forward
window.onpopstate = function(e) {
updateURL(window.location.pathname + window.location.search);
};
// render view
render(<App {...getState()} />, document.getElementById('app'));
index.js
Now when a user navigates back or forward, or enters the site with a special url, the app's state will update accordingly.
Similarly, when we generate urls in code for links or other purposes, we can apply them to state by invoking updateURL
.
Links
Now that we can acquire urls, apply them to state, and display them, the last piece of the puzzle is generating them in code.
Where in an app should that be done?
Since urls are representations of an app's state, and can be derived deterministically from state - generated urls are fundamentally selectors.
They are not stored in state, or managed by the state container at all.
Here is an example of a selector that builds a link to a specific product page using productID
and productName
state:
import updateURL from '../site/actions/update-url';
export default function (productID, productName) {
const url = `/product/${productID}`;
return {
label: productName,
href: url,
onClick: () => updateURL(url)
};
});
site-header.js has links too
Invoking onClick
will update the entire app's state to show the specific product.
Putting it all together
Those are all the pieces needed for a full-featured, dependency-free, dsl-free, state-driven routing solution. Here is a walk-through of it working together: a user lands on the app, the index file runs, reads the url from the address bar, and sends it to redux to set initial state. The user clicks on a link, the app intercepts the click, sends the url to redux, state updates, sends the url to the view, view updates address bar with new url. The user hits backspace on the keyboard to go back to the previous page, the browser changes the url in the address bar, notifies the app, the app updates its state through redux with the new (previous) url.
Bonus
This is not particular to state-driven routing, but important to keep in mind.
Links using <a>
tags are a bit more nuanced than people realize, especially when it comes to single page apps.
There is the main-line case of client-side navigation with preventDefault
and js taking over.
Then there are the exceptional cases:
- ctrl-clicking to open a new tab
- shift-clicking to open a new window
- alt-clicking
- non-left-clicking
target
attribute in <a>mailto:
in href
These exceptions need to be handled natively by the browser, while the rest are dealt with as described in this article. React router has a special Link class that redirects exceptions to the browser. A more generalized implementation that does the same is link-react. No matter how you route, they have to be addressed.
Code
See the demo and source on github