commit 31db9312f8a302bc52ec6644c7bcbd663ca12ff6
parent 310bb5dd1d5dca996eaf16d1d6eaaf6d5981b246
Author: Konstantin Tarkus <hello@tarkus.me>
Date: Wed, 20 Jul 2016 06:58:47 +0300
Merge PR #756: Update how-to-implement-routing.md
Diffstat:
1 file changed, 189 insertions(+), 109 deletions(-)
diff --git a/docs/recipes/how-to-implement-routing.md b/docs/recipes/how-to-implement-routing.md
@@ -1,137 +1,217 @@
-## How to Implement Routing and Navigation [](https://github.com/kriasoft/react-starter-kit/issues/116)
+## How to Implement Routing and Navigation
- * [Step 1: Basic Routing](#step-1-basic-routing)
- * [Step 2: Asynchronous Routes](#step-2-asynchronous-routes)
- * [Step 3: Parameterized Routes](#step-3-parameterized-routes)
- * Step 4: Handling Redirects
- * Step 5: Setting Page Title and Meta Tags
- * Step 6: Code Splitting
- * Step 7: Nested Routes
- * Step 8: Integration with Flux
- * Step 9: Server-side Rendering
+Let's see how a custom routing solution under 100 lines of code may look like.
-### Step 1: Basic Routing
+First, you will need to implement the **list of application routes** in which each route can be
+represented as an object with properties of `path` (a parametrized URL path string), `action`
+(a function), and optionally `children` (a list of sub-routes, each of which is a route object).
+The `action` function returns anything - a string, a React component, etc. For example:
-In its simplest form the routing looks like a collection of URLs where each URL
-is mapped to a React component:
+#### `src/routes/index.js`
```js
-// client.js
-import React from 'react';
-import Layout from './components/Layout';
-import HomePage from './components/HomePage';
-import AboutPage from './components/AboutPage';
-import NotFoundPage from './components/NotFoundPage';
-import ErrorPage from './components/ErrorPage';
-
-const routes = {
- '/': <Layout><HomePage /></Layout>,
- '/about': <Layout><AboutPage /></Layout>
-};
-
-const container = document.getElementById('app');
-
-function render() {
- try {
- const path = window.location.hash.substr(1) || '/';
- const component = routes[path] || <NotFoundPage />;
- React.render(component, container);
- } catch (err) {
- React.render(<ErrorPage {...err} />, container);
+export default [
+ {
+ path: '/tasks',
+ action() {
+ const resp = await fetch('/api/tasks');
+ const data = await resp.json();
+ return data && {
+ title: `To-do (${data.length})`,
+ component: <TodoList {...data} />
+ };
+ }
+ },
+ {
+ path: '/tasks/:id',
+ action({ params }) {
+ const resp = await fetch(`/api/tasks/${params.id}`);
+ const data = await resp.json();
+ return data && {
+ title: data.title,
+ component: <TodoItem {...data} />
+ };
+ }
}
-}
-
-window.addEventListener('hashchange', () => render());
-render();
+];
```
-### Step 2: Asynchronous Routes
+Next, implement a **URL Matcher** function that will be responsible for matching a parametrized
+path string to the actual URL. For example, calling `matchURI('/tasks/:id', '/tasks/123')` must
+return `{ id: '123' }` while calling `matchURI('/tasks/:id', '/foo')` must return `null`.
+Fortunately, there is a great library called [`path-to-regexp`](https://github.com/pillarjs/path-to-regexp)
+that makes this task very easy. Here is how a URL matcher function may look like:
-Just wrap React components inside your routes into asynchronous functions:
+#### `src/core/router.js`
```js
-import React from 'react';
-import fetch from './core/fetch';
-import Layout from './components/Layout';
-import HomePage from './components/HomePage';
-import AboutPage from './components/AboutPage';
-import NotFoundPage from './components/NotFoundPage';
-import ErrorPage from './components/ErrorPage';
-
-const routes = {
- '/': async () => {
- const response = await fetch('/graphql?query={content(path:"/"){title,html}}');
- const data = await response.json();
- return <Layout><HomePage {...data} /></Layout>
- },
- '/about': async () => {
- const response = await fetch('/graphql?query={content(path:"/about"){title,html}}');
- const data = await response.json();
- return <Layout><AboutPage {...data} /></Layout>;
+import toRegExp from 'path-to-regexp';
+
+function matchURI(path, uri) {
+ const keys = [];
+ const pattern = toRegExp(path, keys); // TODO: Use caching
+ const match = pattern.exec(uri);
+ if (!match) return null;
+ const params = Object.create(null);
+ for (let i = 1; i < match.length; i++) {
+ params[keys[i - 1].name] =
+ match[i] !== undefined ? match[i] : undefined;
}
-};
-
-const container = document.getElementById('app');
-
-async function render() {
- try {
- const path = window.location.hash.substr(1) || '/';
- const route = routes[path];
- const component = route ? await route() : <NotFoundPage />;
- React.render(component, container);
- } catch (err) {
- React.render(<ErrorPage {...err} />, container);
+ return params;
+}
+```
+
+Finally, implement a **Route Resolver** function that given a list of routes and a URL/context
+should find the first route matching the provided URL string, execute its action method, and if the
+action method returns anything other than `null` or `undefined` return that to the caller.
+Otherwise, it should continue iterating over the remaining routes. If none of the routes match to the
+provided URL string, it should throw an exception (Not found). Here is how this function may look like:
+
+#### `src/core/router.js`
+
+```js
+import toRegExp from 'path-to-regexp';
+
+function matchURI(path, uri) { ... } // See above
+
+async function resolve(routes, context) {
+ for (const route of routes) {
+ const uri = context.error ? '/error' : context.pathname;
+ const params = matchURI(route.path, uri);
+ if (!params) continue;
+ const result = await route.action({ ...context, params });
+ if (result) return result;
}
+ const error = new Error('Not found');
+ error.status = 404;
+ throw error;
}
-window.addEventListener('hashchange', () => render());
-render();
+export default { resolve };
+```
+
+That's it! Here is a usage example:
+
+```js
+import router from './core/router';
+import routes from './routes';
+
+router.resolve(routes, { pathname: '/tasks' }).then(result => {
+ console.log(result);
+ // => { title: 'To-do', component: <TodoList .../> }
+});
```
-### Step 3: Parameterized Routes
+While you can use this as it is on the server, in a browser environment it must be combined with a
+client-side navigation solution. You can use [`history`](https://github.com/ReactTraining/history)
+npm module to handles this task for you. It is the same library used in React Router, sort of a
+wrapper over [HTML5 History API](https://developer.mozilla.org/docs/Web/API/History_API) that
+handles all the tricky browser compatibility issues related to client-side navigation.
+
+First, create `src/core/history.js` file that will initialize a new instance of the `history` module
+and export is as a singleton:
-**(1)** Convert the list of routes from hash table to an array, this way the
-order of routes will be preserved. **(2)** Wrap this collection into a Router
-class, where you can put `.match(url)` async method. **(3)** Use [path-to-regexp](https://github.com/pillarjs/path-to-regexp)
-to convert Express-like path strings into regular expressions which are used
-for matching URL paths to React components.
+#### `src/core/history.js`
```js
-import React from 'react';
-import Router from 'react-routing/src/Router';
-import fetch from './core/fetch';
-import Layout from './components/Layout';
-import ProductListing from './components/ProductListing';
-import ProductInfo from './components/ProductInfo';
-import NotFoundPage from './components/NotFoundPage';
-import ErrorPage from './components/ErrorPage';
-
-const router = new Router(on => {
- on('/products', async () => {
- const response = await fetch('/graphql?query={products{id,name}}');
- const data = await response.json();
- return <Layout><ProductListing {...data} /></Layout>
- });
- on('/products/:id', async ({ params }) => {
- const response = await fetch('/graphql?query={product(id:"${params.id}"){name,summary}}');
- const data = await response.json();
- return <Layout><ProductInfo {...data} /></Layout>;
- });
-}]);
+import createHistory from 'history/lib/createBrowserHistory';
+import useQueries from 'history/lib/useQueries';
+export default useQueries(createHistory)();
+```
-const container = document.getElementById('app');
+Then plug it in, in your client-side bootstrap code as follows:
-async function render() {
- const state = { path: window.location.hash.substr(1) || '/' };
- await router.dispatch(state, component => {
- React.render(component, container);
+#### `src/client.js`
+
+```js
+import ReactDOM from 'react-dom';
+import history from './core/history';
+import router from './core/router';
+import routes from './routes';
+
+const container = document.getElementById('root');
+
+function renderRouteOutput({ title, component }) {
+ ReactDOM.render(component, container, () => {
+ document.title = title;
});
}
-window.addEventListener('hashchange', () => render());
-render();
+function render(location) {
+ router.resolve(routes, location)
+ .then(renderRouteOutput)
+ .catch(error => router.resolve(routes, { ...location, error })
+ .then(renderRouteOutput));
+}
+
+render(history.getCurrentLocation()); // render the current URL
+history.listen(render);
+```
+
+Whenever a new location is pushed into the `history` stack, the `render()` method will be called,
+that itself calls the router's `resolve()` method and renders the returned from it React component
+into the DOM.
+
+In order to trigger client-side navigation without causing full-page refresh, you need to use
+`history.push()` method, for example:
+
+```js
+import React from 'react';
+import history from '../core/history';
+
+class App extends React.Component {
+ transition = event => {
+ event.preventDefault();
+ history.push({
+ pathname: event.currentTarget.pathname,
+ search: event.currentTarget.search
+ });
+ };
+ render() {
+ return (
+ <ul>
+ <li><a href="/" onClick={this.transition}>Home</a></li>
+ <li><a href="/one" onClick={this.transition}>One</a></li>
+ <li><a href="/two" onClick={this.transition}>Two</a></li>
+ </ul>
+ );
+ }
+}
+```
+
+Though, it is a common practice to extract that transitioning functionality into a stand-alone
+(`Link`) component that can be used as follows:
+
+```html
+<Link to="/tasks/123">View Task #123</Link>
```
-### Step 4. Handling Redirects
+### Routing in React Starter Kit
+
+React Starter Kit (RSK) uses [Universal Router](https://github.com/kriasoft/universal-router) npm
+module that is built around the same concepts demonstrated earlier with the major differences that
+it supports nested routes and provides you with the helper `Link` React component. It can be seen as
+a lightweight more flexible alternative to React Router.
+
+- It has simple code with minimum dependencies (just `path-to-regexp` and `babel-runtime`)
+- It can be used with any JavaScript framework such as React, Vue.js etc
+- It uses the same middleware approach used in Express and Koa, making it easy to learn
+- It uses the exact same API and implementation to be used in both Node.js and browser environments
+
+The [Getting Started page](https://github.com/kriasoft/universal-router/blob/master/docs/getting-started.md)
+has a few examples how to use it.
+
+### Related Articles
+
+- [You might not need React Router](https://medium.freecodecamp.com/you-might-not-need-react-router-38673620f3d) by Konstantin Tarkus
+
+### Related Projects
+
+- [`path-to-regexp`](https://github.com/pillarjs/path-to-regexp)
+- [`history`](https://github.com/ReactTraining/history)
+- [Universal Router](https://github.com/kriasoft/universal-router)
+
+### Related Discussions
-Coming soon. Stay tuned!
+- [How to Implement Routing and Navigation](https://github.com/kriasoft/react-starter-kit/issues/748)
+- [How to Add a Route to RSK?](https://github.com/kriasoft/react-starter-kit/issues/754)