Welcome back! In our last session we took a look at how to manage data storage in React applications, examining a basic Flux architecture and talking about what third-party Flux offerings bring to the table. We built a basic network-enabled GitHub commit viewer, using the xhr
library with redux
as our backing store. Now, we'll see how to build a more expansive React application, including:
- An introduction to
react-router
- Integrating
react-router
with our Github Repo Viewer
Let's get right into it!
Complex applications have more than just a single screen. Traditional web development stacks use a full-reload process; you click a link, and the browser reloads the entire page based on the response from the server. When you're building a single-page architecture, this isn't ideal - each new request will download the same files over and over again, just to display your app. To avoid this, we want to handle routing concerns on the client side - when a link is clicked, we should catch it and react accordingly.
As a quick review, our current GitHub repository viewer has a structure like the below:
src
app.js
components
repository-index.js
commit-message.js
stores
actions.js
store.js
We only have one main component at the moment (components/repository-index.js
), but as we add new ones we need a solution for catching URL actions. Enter React Router, a project that integrates routing into React applications with ease. It abstracts out many tedious and sometimes difficult tasks, such as handling URL changes and interfacing with React component lifecycle methods to enable proper loading.
Let's see how our current application would integrate routing. To follow along, you'll want to install react router:
npm install --save react-router
Now we just need to add a few imports, and change our render method ever so slightly:
import React from 'react';
import {render} from 'react-dom';
import {Router, Route} from 'react-router';
import RepositoryIndex from './components/repository-index';
class App extends React.Component {
constructor(props) {
super(props);
this.state = {username: 'ryanmcgrath', repo: 'twython'};
this.handleKeyEvent = this.handleKeyEvent.bind(this);
}
handleKeyEvent(e) {
if(e.key !== 'Enter')
return;
var data = this.refs.input.value.split('/');
if(data.length !== 2)
return alert('Bad input.');
this.setState({
username: data[0],
repo: data[1]
});
}
render() {
return (<div id="app_wrapper">
<input type="text" ref="input" placeholder={this.state.username + "/" + this.state.repo} onKeyUp={this.handleKeyEvent} />
<RepositoryIndex username={this.state.username} repo={this.state.repo} />
</div>);
}
}
render((
<Router>
<Route path="/" component={App} />
</Router>
), document.getElementById('app'));
The Router
and Route
objects are just React components, at the end of the day - we can just pass them to the render
method. We pass our App as the index route; as we move to add new routes we have a simple point to do so. The Router handles catching the URL and checking to see if we should render a different component, including capturing URL components and passing them as props down the component chain. All your standard React lifecycle methods behave as they should, making the entire stack very straightforward to reason about.
There's a few intricacies in regards to what react-router
brings to the table that are worth reviewing. Some of the other components worth knowing about are:
-
<Link>
A component that you can use in your custom component to generate fully-qualified links that work with your routing structure. In addition, it'll detect when a link that's been clicked is considered "active", which helps the UI side of things if you'd like to light up a navigation item or display a tree of some sort. Passing the standardid
,className
and so on will ensure they get passed to the generated<a></a>
HTML. -
<IndexLink>
Similar to<Link>
, but will be considered "active" only when the exact route is matched -<Link>
treats routes below your route as active as well. -
<Redirect>
URLs in applications can change as time goes on. As your application grows, hunting down where your URLs are nestled in your code and updating them can be tricky, and this only grows as multiple people touch the codebase.<Redirect>
will ensure that an old URL passes over to the new one, in one central location.You'd use it something like this:
<Redirect from="old_url" to="new_url" />
-
<IndexRoute>
A simple way to provide a default route to a parent route. A use case for this would be if we wanted to have multiple sub-routes of/
, but render something specifically on the root/
.
React Router keeps a few properties handy, and injecting specific properties into your component structure provides you a few useful pieces:
-
location
A location object that's conceptually similar towindow.location
, but with a few extra things behind the scenes for React Router use-cases. -
params
URL parameters, useful for passing pieces of data around. For instance, if your URL was '/users/user/1', and your url configuration was '/users/user/:userid', then in your componentthis.props.params
would be{userid: 1}
. -
route
The route that's rendering your component. Useful for debugging purposes.
History in React Router is taken care of by a library (aptly) known as history, which maps over the differences in browsers that tend to complicate URL routing in client-side applications. For the most part you can set-and-forget with this, but there are three different ways to use history in React Router that have their uses:
-
browserHistory
The default history store that you'll work with, which uses the built-inHistory
API found in modern browsers. IE9 and below don't support this API, though, so if you need to support these browsers you'll want to look at the next item... -
hashHistory
WhereasbrowserHistory
uses the shiny newHistory
API to do clean URL structures,hashHistory
builds... uglier, but functional ones. These will work in IE9 and below, looking akin to/#/my/url/state
. They're not ideal for users to remember, and technically are a bit of a hack, but they work. -
createMemoryHistory
A history shim that doesn't require a browser. Useful for testing, or environments like React Native.
The project we've been hacking on so far has been a bit limited in scope - Flux came along in session 2 and gave us data storage capabilities, but there's no real interactivity besides typing in a username/repository
combination. Let's use React Router to simplify this - instead of having to supply a combination, we'll make it so all you have to do is supply a username, and the repositories for that user will be listed out. Clicking on one will show the recent commit log for that repository.
Let's refine our app.js
file first, as it's the entry point where everything boots off of. We'll be creating a new component shortly, called RepositoryList
, so we'll import it here in preparation - it's just a view that lists repositories for a given username. We also add in an extra import from react-router
to grab browserHistory
; this is a provided singleton that makes life easier when dealing with the History interface, and allows us to push a new URL entry onto the History stack as well, triggering a React Router update in the process.
We'll also swap out our ad-hoc onKeyUp
function for a more robust form onSubmit
; when this occurs, we push the username onto our browser history stack to navigate to it.
import React from 'react';
import {render} from 'react-dom';
import {Router, Route, browserHistory} from 'react-router';
import RepositoryIndex from './components/repository-index';
import RepositoryList from './components/repository-list';
class App extends React.Component {
constructor(props) {
super(props);
this.handleSubmit = this.handleSubmit.bind(this);
}
getChildContext() {
return {params: this.props.params};
}
handleSubmit(e) {
e.preventDefault();
let username = this.refs.input.value;
browserHistory.push('/' + username);
}
render() {
return (
<div id="app_wrapper">
<form method="get" action="/" onSubmit={this.handleSubmit}>
<input type="text" ref="input" placeholder="Enter a GitHub username and hit enter" />
</form>
{this.props.children}
</div>
);
}
}
App.childContextTypes = {params: React.PropTypes.object};
render((
<Router history={browserHistory}>
<Route path="/" component={App}>
<Route path=":username" component={RepositoryList} />
<Route path=":username/:repository" component={RepositoryIndex} />
</Route>
</Router>
), document.getElementById('app'));
There's a few things to take note of here:
-
In our
<App/>
component, we explicitly include{this.props.children}
in the render method. Route's are just React.Component instances themselves, and<App />
is like the base of the application - for each url that's hit, React Router will pass our desired component as a child to<App />
. Doing so just ensures the rendering chain takes place. -
getChildContext
is a slightly more advanced feature in React, known as Contexts. A parent component (in this case, our<App />
) can provide default properties to every child it contains. This is useful for emulating global variables or shared data while keeping it confined to one React tree, and React Router uses it to passparams
down the chain. This allows us to pull URL properties and variables in our child components. -
We set
childContextTypes
onApp
; this is just informing React how our properties will look. -
Our Router structure is simple, but very flexible - on
/:username
we'll list repositories for that user, and on/:username/:repository
we'll load the commits for that repository.
Now, we'll need a few other pieces in place for this all to work. Create src/components/repository-list.js
next, so our imports don't blow up - this will be barebones, but we'll come back to it after hitting our data layer:
import React from 'react';
import RepositoryLink from './repository-link';
class RepositoryList extends React.Component {
render() {
return (<div id="repository-list">
<h1>Showing Repositories {this.props.params.username} Contributes To:</h1>
</div>);
}
}
export default RepositoryList;
And go ahead and create src/components/repository-link.js
as well:
import React from 'react';
import {Link} from 'react-router';
class RepositoryLink extends React.Component {
render() {
let url = this.props.repo.owner.login + '/' + this.props.repo.name;
return (
<Link to={url} className="repo-link">
<h2>{url}</h2>
<p>{this.props.repo.description}</p>
<p><small>{this.props.repo.watchers_count} Watchers</small></p>
</Link>
);
}
};
export default RepositoryLink;
This component is one we can scaffold ahead of time, as the data structure for a repository is documented. We'll show the username/repo
combo, the description of the repository, and a watchers count.
Now that we've got a basic hold on how our UI structure is getting set up, we'll need to add alter our actions
and store
a bit to accommodate this. First up, src/store/actions.js
:
import xhr from 'xhr';
export const ADD_USER = 'ADD_USER';
export const LOAD_USER = 'LOAD_USER';
export const LOAD_REPOSITORY = 'LOAD_REPOSITORY';
const github = 'https://api.github.com/';
export const addUser = function(username) {
return {
type: ADD_USER,
username: username
};
};
export const loadUser = function(username) {
return function(dispatch) {
let url = github + 'users/' + username + '/repos';
xhr.get(url, {json: true}, function(error, response, body) {
dispatch({
type: LOAD_USER,
username: username,
repositories: body
});
});
};
};
export const loadRepository = function(username, repo) {
return function(dispatch) {
let url = 'https://api.github.com/repos/' + username + '/' + repo + '/commits';
xhr.get(url, {json: true}, function(error, response, body) {
dispatch({
type: LOAD_REPOSITORY,
username: username,
repo: repo,
commits: body
});
});
};
};
This may look like old territory after session 2, but it's useful all the same.
-
A few new constants are necessary -
ADD_USER
andLOAD_USER
. -
We've got two new actions as well -
addUser
, which just standardizes the format for adding a user, andloadUser
, which is aredux-thunk
action likeloadRepository
. In there we simply construct our URL and load the repositories. -
We also abstract out the GitHub domain into a variable.
You might wonder why we have two separate methods for adding a user, and loading a user. The reason is because our data structure relies on the username being present, and now we've got two different views that could be loaded - a user could refresh the browser entirely on
/:username/:repository
, and we should load the commits for that repository.addUser
simply ensures that an entry for the username always exists in our Redux tree, so thatLOAD_USER
andLOAD_REPOSITORY
need not care about this logic.
Now let's flip over to the companion to this file, src/store/store.js
:
import {createStore, applyMiddleware} from 'redux';
import thunk from 'redux-thunk';
import xhr from 'xhr';
import {ADD_USER, LOAD_USER, LOAD_REPOSITORY} from './actions';
const createStoreWithMiddleware = applyMiddleware(thunk)(createStore);
const store = createStoreWithMiddleware(function(state, action) {
let newState = Object.assign({}, state);
switch(action.type) {
case ADD_USER:
if(!newState[action.username])
newState[action.username] = {};
return newState;
case LOAD_USER:
action.repositories.forEach(function(repository) {
newState[action.username][repository.name] = repository;
});
return newState;
case LOAD_REPOSITORY:
newState[action.username][action.repo] = action.commits;
return newState;
default:
return state
}
}, {});
export default store;
Thanks to Redux, this all stays pretty smooth - we've got two new cases to deal with. ADD_USER
is just logic ensuring that our username exists, and LOAD_USER
iterates over our returned repository list and stores it on the user by key. This is one way to do it - you could also substitute the repositories Object for an Array, for instance. Feel free to play around with it.
With our data layer sorted out, we can finish stringing together our view components. Thanks to React Router, our RepositoryList
now gets the username
from the URL, so we'll head back there to hook up the data layer. Open up src/components/repository-list.js
:
import React from 'react';
import store from '../store/store';
import {addUser, loadUser} from '../store/actions';
import RepositoryLink from './repository-link';
class RepositoryList extends React.Component {
constructor(props) {
super(props);
this.state = {repositories: []};
this.updateList = this.updateList.bind(this);
}
componentDidMount() {
store.dispatch(addUser(this.props.params.username));
this.unsubscribe = store.subscribe(this.updateList);
store.dispatch(loadUser(this.props.params.username));
}
componentWillReceiveProps(newProps) {
store.dispatch(addUser(newProps.params.username));
store.dispatch(loadUser(newProps.params.username));
}
componentWillUnmount() {
this.unsubscribe();
}
updateList() {
let user = store.getState()[this.props.params.username],
repositories = [];
Object.keys(user).forEach(function(key, i) {
repositories.push(Object.assign({}, user[key]));
});
this.setState({
repositories: repositories
});
}
render() {
return (<div id="repository-list">
<h1>Showing Repositories {this.props.params.username} Contributes To:</h1>
{this.state.repositories.map(function(repo, i) {
return <RepositoryLink repo={repo} key={i} />;
})}
</div>);
}
}
export default RepositoryList;
We're just doing the usual lifecycle methods, harkening back to session 2. The important pieces are...
-
On
componentDidMount()
andcomponentWillReceiveProps()
, we fire off an action to ensure our user record exists before loading up the user data. -
updateList()
is where the bulk of the magic happens; to make therender()
method more succinct, we transform the repositories Object into a flat Array that we can just map over.
After that, our first two screens should load A-OK! Give it a whirl with npm start
and check it out - you should be able to type in a username and get their repository list back. If you'd like some presentable CSS, there's an app.css
file in the root of this repository that you're more than welcome to grab.
The last part we need to hit is RepositoryIndex
, which we created back in session 2. There's not many huge changes here, other than swapping out property reference names to work with React Router. We'll go ahead and add an addUser
action call here as well, in case a user were to reload while on this view, and make our updateRepo()
method a bit more succinct:
import React from 'react';
import store from '../store/store';
import {addUser, loadRepository} from '../store/actions';
import CommitMessage from './commit-message';
class RepositoryIndex extends React.Component {
constructor(props) {
super(props);
this.state = {history: []};
this.updateRepo = this.updateRepo.bind(this);
}
componentDidMount() {
store.dispatch(addUser(this.props.params.username));
this.unsubscribe = store.subscribe(this.updateRepo);
store.dispatch(loadRepository(this.props.params.username, this.props.params.repository));
}
componentWillReceiveProps(newProps) {
store.dispatch(loadRepository(newProps.params.username, newProps.params.repository));
}
componentWillUnmount() {
this.unsubscribe();
}
updateRepo(history) {
let data = store.getState()[this.props.params.username];
if(data[this.props.params.repository])
data = data[this.props.params.repository];
else
data = [];
this.setState({history: [].concat(data)});
}
render() {
return <ul className="repository">
<h1>Showing Recent Commits on {this.props.params.username}/{this.props.params.repository}</h1>
{this.state.history.map(function(commit, i) {
return <CommitMessage commit={commit} key={i} />;
})}
</ul>;
}
};
export default RepositoryIndex;
And voilĂ ! Our GitHub repository viewer is now router-enabled. You should be able to load up a user, see their repositories, and read the latest commits for a given repository. One final change we can also make to our project is to enable the historyApiFallback
option in webpack.config.js
- by default, a user reloading on /:username/repository
will get a 404. Since this is just development, you're likely the only user; not a huge deal, but it can be annoying to deal with.
In your devServer
option in webpack.config.js
, add historyApiFallback: true
:
// Configure our development server
config.devServer = {
contentBase: __dirname,
hot: true,
progress: true,
stats: 'errors-only',
host: process.env.HOST,
port: process.env.PORT,
historyApiFallback: true,
};
Note: If you experience issues with specifying it here, you can also specify it in the
npm start
script by adding--history-api-fallback
. I've experienced more luck with that in some situations, and documentation around the web differs.
React Router is just one way to do all of this, too - if you're interested in coupling your Router to your state, react-router-redux may be of instance to you.
In our next and final session, we'll be looking at integrating non-React components into React applications, as well as a review of best practices with regards to React development. We'll also extend our GitHub viewer one last time, to bring it all together. If you have any questions, reach out to me! Always happy to go over anything covered here.