React.js tutorial

SPA したくて React.js を始めたい人に手っ取り早く動かせるように tutorial を作りました。学習コストを下げるために redux を避けているので割ととっつきやすいと思います。たぶん。

この記事にあるソースコードはこちらに置いてあります。

https://github.com/okamuuu/fav-articles

## STEP0: 準備

この tutorial では `create-react-app` コマンドを使うのでこれを最初に install します。

```
npm install -g create-react-app
```

このコマンドを使ってプロジェクトフィアル一式を生成します。

```
create-react-app fav-articles && cd $_
```

## STEP1: Create fake server

`json-server` はとても便利なライブラリです。`faker` もなかなか便利なのでこれらを使って mock server を作ります。

```
npm install --save json-server faker
touch server.js
```

create server.js

```javascript
const faker = require('faker')
const jsonServer = require('json-server')
const server = jsonServer.create()

server.use((req, res, next) => {
  res.header("Access-Control-Allow-Origin", "http://localhost:3000")
  res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept")
  res.header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS")
  next()
})

const rewriter = jsonServer.rewriter({'/api/*': '/$1'})
const router = jsonServer.router(getArticles())

server.use(rewriter)
server.use(router)

function getArticles() {
  const articles = []

  for (var id = 1; id < 51; id++) {

    articles.push({
      "id": id,
      "title": faker.lorem.words(),
      "description": faker.lorem.paragraphs(),
	  "isFavorite": false
    })
  }

  return { "articles": articles }
}

module.exports = server
```

edit `package.json`

```
     "start": "react-scripts start",
     "build": "react-scripts build",
     "test": "react-scripts test --env=jsdom",
-    "eject": "react-scripts eject"
+    "eject": "react-scripts eject",
+    "serve": "node -e \"require('./server').listen(4000)\""
   }
```

動作確認

```
npm run serve

> practice-app@0.1.0 serve /Users/okamuuu/practice-app
> node -e "require('./server').listen(4000)"
```

50件の記事が表示されることを確認します。

```
open http://localhost:4000/api/articles
```

## STEP2: Create Api class and the test case

WEB API との通信には `axios` と `parse-link-header`　を使います。

```
npm install --save axios parse-link-header
touch src/Api.js src/Api.test.js
```

create `src/Api.js`

```javascript
import axios from 'axios'
import httpAdapter from 'axios/lib/adapters/http'
import parse from 'parse-link-header'

// https://github.com/mzabriskie/axios/issues/305#issuecomment-233141731
// for jest. force the node adapter
if (process.env.NODE_ENV == "test") {
  axios.defaults.adapter = httpAdapter
}

export default class Api {

  constructor(baseUrl) {
    this.baseUrl = baseUrl
  }

  listArticles(page=1) {
    return axios.get(`${this.baseUrl}/api/articles?_page=${page}`).then((res) => {
      return {
        "articles": res.data || [], 
        "links": parse(res.headers.link)
      }   
    })  
  }
  
  listFavoriteArticles(page=1) {
    return axios.get(`${this.baseUrl}/api/articles?isFavorite=true&_page=${page}&_limit=50`).then((res) => {
      return {
        "articles": res.data || [], 
        "links": parse(res.headers.link)
      }   
    })  
  }
  
  showArticle(id) {
    return axios.get(`${this.baseUrl}/api/articles/${id}`).then((res) => {
      return { "article": res.data }
   })
  }

  updateArticle(id, params) {
    return axios.put(`${this.baseUrl}/api/articles/${id}`, params).then((res) => {
      return { "article": res.data }
    })
  }
}
```

create `src/Api.test.js`

```javascript
import server from '../server'
import Api from '../src/Api'

const port = server.listen(0).address().port
const api = new Api(`http://127.0.0.1:${port}`)

describe('Api', function() {

  test('listArticles', async () => {
    const result = await api.listArticles()
    expect(result.articles.length).toEqual(10)
  })  
})
```

`CI=true npm run test` を実行すると単体テストができます。

```
% CI=true npm test

> fav-articles@0.1.0 test /Users/okamuuu/fav-articles
> react-scripts test --env=jsdom

 PASS  src/Api.test.js
 PASS  src/App.test.js

Test Suites: 2 passed, 2 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        1.843s
Ran all test suites.
```

## STEP3: A few Designing

bootstrap のデザインをつかいます。

```
npm install --save bootstrap
```

edit `src/index.js`

```
 import React from 'react';
 import ReactDOM from 'react-dom';
 import App from './App';
+import 'bootstrap/dist/css/bootstrap.css'
 import './index.css';
```

このやり方だと css だけしか使えないのですが今回は Bootstrap の JavaScript を使わないのでここまででOKです。もし Bootstrap の JavaScript を使いたい場合は下記の記事を参考にして下さい。

http://okamuuu.hatenablog.com/entry/2016/07/18/115404

## STEP4: react-router

`react-router` を使用します。最近 v3 から v4 に変更されています。

```
npm install --save react-router-dom
touch src/Articles.js
```

create `src/Articles.js`

```javascript
import React, {Component} from 'react'
import { Link } from 'react-router-dom'

class List extends Component {

  render() {
    return (
      <div>
        <h2>Articles</h2>
        <ul>
          <li><Link to="/articles/1">Show 1</Link></li>
          <li><Link to="/articles/2">Show 2</Link></li>
          <li><Link to="/articles/3">Show 3</Link></li>
        </ul>
      </div>
    )
  }
}

class FavoriteList extends Component {

  render() {
    return (
      <div>
        <h2>Favorite Articles</h2>
	  </div>
    )
  }
}

class Show extends Component {

  render() {
    return (
      <div>
        <h3>Show {this.props.match.params.id}</h3>
      </div>
    )
  }
}

export default { List, FavoriteList, Show }
```

edit `src/App.js`

```javascript
import React from 'react'
import { BrowserRouter as Router, Switch, Route, Link, NavLink, withRouter } from 'react-router-dom'

import Articles from './Articles'

const Header = ({onClick}) => (
  <h1 className="text-center" style={{cursor: "pointer" }} onClick={onClick}>Favorite Articles</h1>
)

const Nav = () => (
  <ul className="nav nav-pills">
    <li><NavLink exact to="/articles">Articles</NavLink></li>
    <li><NavLink exact to="/articles/favorite">Favorite Articles</NavLink></li>
  </ul>
)

const Footer = () => (<p className="text-center">Favorite Articles</p>)

const Routes = withRouter(({history}) => (
  <div className="container">
    <Header onClick={() => history.push("/")} />
    <Nav />
    <Switch>
      <Route exact path="/" component={Articles.List}/>
      <Route exact path="/articles" component={Articles.List}/>
      <Route exact path="/articles/favorite" component={Articles.FavoriteList}/>
      <Route exact path="/articles/:id" component={Articles.Show}/>
    </Switch>
    <Footer />
  </div>
))

const App = () => (
  <Router>
    <Routes />
  </Router>
)

export default App
```

`npm start` して `open http://localhost:3000` 動作確認をします。

`react-router@v4` に関しては下記の記事も参考にしてみてください。

http://okamuuu.hatenablog.com/entry/2017/03/23/140650

## STEP5: Using Api Class

最初に作成した Api を組み込みます。

edit `src/Articles.js`

```javascript
import React, {Component} from 'react'
import { Link } from 'react-router-dom'

import Api from './Api'

const api = new Api(`http://127.0.0.1:4000`)

class List extends Component {

  constructor(props) {
    super(props)
    this.state = { articles: [] }
  }

  componentWillMount() {
    api.listArticles().then((result) => {
      this.setState({articles: result.articles, current: 1, last: result.links.last._page})
    })
  }

  render() {
    return (
      <div>
        <h2>Articles</h2>
        <ul>
          {this.state.articles.map((x, index) => (
            <li key={index}>
              <Link to={`/articles/${x.id}`}>{x.title}</Link>
            </li>
          ))}
        </ul>
      </div>
    )
  }
}

class FavoriteList extends Component {

  render() {
    return (
      <div>
        <h2>Favorite Articles</h2>
      </div>
    )
  }
}

class Show extends Component {

  constructor(props) {
    super(props)
    this.state = { article: {} }
  }

  componentWillMount() {
    const { id } = this.props.match.params
    api.showArticle(parseInt(id, 10)).then((result) => {
      this.setState({article: result.article})
    })
  }

  render() {
    const {article} = this.state
    return (
      <div>
        <h2>{article.title}</h2>
        <p>{article.description}</p>
      </div>
    )
  }
}

export default { List, FavoriteList, Show }
```

再度 `npm start` and `open http://localhost:3000` で動作確認をして下さい。
なお、`npm run serve` が起動していないと WEB API の通信に失敗します。

## STEP6: Add Favorite Toggle Button

SVG なデータをアイコンとして使うケースがあると思いますが、 `react-icons` に主要なアイコンが揃っているのでそれを使います。

```
npm install --save react-icons immutable
```

edit `src/Articles.js` to add FavoriteButton and update FavoriteList function.

```javascript
import React, {Component} from 'react'
import { Link } from 'react-router-dom'

import FaStar from 'react-icons/lib/fa/star'
import immutable from 'immutable'

import Api from './Api'

const api = new Api(`http://127.0.0.1:4000`)

const FavoriteButton = ({isFavorite, onClick}) => (
  <FaStar style={{cursor: "pointer"}} color={isFavorite ? "#ffa500" : "#eee"} onClick={onClick} />)

class List extends Component {

  constructor(props) {
    super(props)
    this.state = { articles: [] }
  }

  componentWillMount() {
    api.listArticles().then((result) => {
      this.setState({articles: result.articles, current: 1, last: result.links.last._page})
    })
  }

  handleFavorite(article, index) {
    article.isFavorite = article.isFavorite !== true
    api.updateArticle(article.id, article).then((result) => {
      const nextArticles = immutable.List(this.state.articles)
      nextArticles[index] = result.article
      this.setState({articles: nextArticles})
    })  
  }

  render() {
    return (
      <div>
        <h2>Articles</h2>
        <ul>
          {this.state.articles.map((x, index) => (
            <li key={index}>
              <Link to={`/articles/${x.id}`}>{x.title}</Link>
              {" "}
              <FavoriteButton isFavorite={x.isFavorite} onClick={() => this.handleFavorite(x, index)} />
            </li>
          ))}
        </ul>
      </div>
    )
  }
}

class FavoriteList extends Component {

  constructor(props) {
    super(props)
    this.state = { articles: [] }
  }

  componentWillMount() {
    api.listFavoriteArticles().then((result) => {
      this.setState({articles: result.articles})
    })
  }

  render() {

    const {articles} = this.state

    return (
      <div>
        <h2>Favorites</h2>
        <ul>
        {this.state.articles.map((x, index) => (
          <li key={index}><Link to={`/articles/${x.id}`}>{x.title}</Link></li>
        ))}
        </ul>
      </div>
    )
  }
}

class Show extends Component {

  constructor(props) {
    super(props)
    this.state = { article: {} }
  }

  componentWillMount() {
    const { id } = this.props.match.params
    api.showArticle(parseInt(id, 10)).then((result) => {
      this.setState({article: result.article})
    })
  }

  render() {
    const {article} = this.state
    return (
      <div>
        <h2>{article.title}</h2>
        <p>{article.description}</p>
      </div>
    )
  }
}

export default { List, FavoriteList, Show }
```

もう一度 `npm start` した状態で `open http://localhost:3000` で確認します。もう少しで終わります。

## STEP7: Add Pagination

最後に拙作の components を使った Pagination を紹介します。

```
npm install --save react-paginators
```

edit `src/Articles.js`

```diff
diff --git a/src/Articles.js b/src/Articles.js
index 0b97cbd..6c87c01 100644
--- a/src/Articles.js
+++ b/src/Articles.js
@@ -5,6 +5,7 @@ import FaStar from 'react-icons/lib/fa/star'
 import immutable from 'immutable'
 
 import Api from './Api'
+import { Bootstrap3ishPaginator } from 'react-paginators'
 
 const api = new Api(`http://127.0.0.1:4000`)
 
@@ -33,7 +34,17 @@ class List extends Component {
     })
   }
 
+  handlePageClick(page) {
+    api.listArticles(page).then((result) => {
+       this.setState({articles: result.articles, current: page, last: result.links.last._page})
+    })
+  }
+
   render() {
+
+    const current = this.state && this.state.current || 1
+    const last = this.state && parseInt(this.state.last, 10) || 1
+
     return (
       <div>
         <h2>Articles</h2>
@@ -46,6 +57,15 @@ class List extends Component {
             </li>
           ))}
         </ul>
+
+        <div style={{padding: "30px",  display: "flex", justifyContent: "center" }}>
+          <Bootstrap3ishPaginator
+            current={current}
+            last={last}
+            maxPageCount={10}
+            onClick={this.handlePageClick.bind(this)}
+          />
+        </div>
       </div>
     )
   }
@@ -107,3 +127,4 @@ class Show extends Component {
 }
 
 export default { List, FavoriteList, Show }
```

以上です。最後までお読み頂きありがとうございます:)