Help us understand the problem. What is going on with this article?

React + Reduxを使ったWebアプリケーション開発速習会@Wantedly

More than 3 years have passed since last update.

React + Reduxを使ったWebアプリケーション開発速習会@Wantedly のための資料です。

WantedlyではSingle Page Applicationなどより複雑な構成にも耐えられるよう、React + Reduxを中心としたWebフロントエンドの技術スタックを導入しました。

  • ES2015
  • React
  • Redux
  • Immutable.js
  • CSS Modules
  • webpack

導入の経緯などは以前発表した :point_down: の資料を参照ください!

WantedlyにReact + Reduxを導入した話

今回はこれらのスタックを使って実際にSingle Page Application構成のアプリケーションをハンズオンで作成します。

:package: 開発の準備 :package:

開発環境

npmライブラリを使用するため、開発マシンにnode.jsがインストール済みであることを前提としています。

サンプルはv5.1.1で動作確認しています。

% node -v                                                                                                                                                                                        
v5.1.1

今回使うソース

shimpeiws/react-redux-sokushu-practice

GitHub上の :point_up: のソースをgit cloneした後に、ディレクトリ直下で以下を実行してください。

npm install
npm run webpack:serve

もうひとつターミナルのペインを開き、ディレクトリ直下に移動し、以下を実行してください。

npm run serve

localhost:8000にアクセスして画面が表示されれば準備は完了です。

スクリーンショット 2016-06-09 14.31.23.png

完成したサンプル

演習の内容を実装済みのリポジトリです :point_down:

演習でつまったときや、動作の確認に使ってください。

shimpeiws/react-redux-sokushu

起動方法は同じですが、こちらはlocalhost:9000で起動するようになっています。

今回の課題内容

GitHubのIssue機能を模したアプリケーションを作成します。

"一覧画面" "詳細画面" "Issue新規作成画面" の3画面で構成されています。

一覧画面

Issueのデータを一覧で表示しています。行をクリックすることで詳細画面に遷移します。

Issueのstatusなど各種フィルタが実装されます。

スクリーンショット 2016-06-09 18.38.44.png

詳細画面

Issueに対してアサインやラベル、コメントを追加することができます。
スクリーンショット 2016-06-09 18.38.25.png

新規Issue作成画面

Issueを新規追加することができます、titleとcontentは必須欄です。

スクリーンショット 2016-06-09 18.36.52.png

使用するAPI

shimpeiws/react-redux-sokushu-api

APIドキュメント

スクリーンショット 2016-06-13 10.05.41.png

演習中はherokuにデプロイされたものを使います。

ディレクトリ構成

ディレクトリ直下

% tree -L 1                                                                                                                                                                                      [14:34:45]
.
├── .babelrc
├── finished
├── node_modules
├── package.json
├── public
├── server-finished.babel.js
├── server.babel.js
├── src
├── webpack-dev-server.babel.js
└── webpack.config.babel.js

4 directories, 5 files
  • .babelrc
    • babelの設定ファイルが記述されています。基本的なものに加え、state-0の機能が利用可能になっています。
  • package.json
    • 使用しているライブラリや、サーバの起動などのスクリプトが記述されています
  • public
    • このフォルダ配下がlocalhost:8000としてマウントされます
  • server.babel.js
    • public配下をlocalhost:8000で起動します。node(Express)を使っています
  • src
    • 実際に開発するソースです
  • webpack.config.babel.js
    • webpack dev serverからコンパイルされたアセットを配信するための設定が書かれています

/src 配下

% tree -L 3                                                                                [19:12:48]
.
├── actions
│   ├── issue.js
│   ├── issueDetail.js
│   └── issueNew.js
├── components
│   ├── IssueCommentForm.js
│   ├── IssueCommentForm.scss
│   ├── IssueCommentList.js
│   ├── IssueCommentList.scss
│   ├── IssueCommentListItem.js
│   ├── IssueCommentListItem.scss
│   ├── IssueDescription.js
│   ├── IssueDescription.scss
│   ├── IssueDetailHeader.js
│   ├── IssueDetailHeader.scss
│   ├── IssueList.js
│   ├── IssueList.scss
│   ├── IssueListHeader.js
│   ├── IssueListHeader.scss
│   ├── IssueListItem.js
│   ├── IssueListItem.scss
│   ├── IssueNewHeader.js
│   └── IssueNewHeader.scss
├── containers
│   ├── IssueContainer.js
│   ├── IssueDetailContainer.js
│   ├── IssueDetailContainer.scss
│   ├── IssueListContainer.js
│   ├── IssueListContainer.scss
│   ├── IssueNewContainer.js
│   └── IssueNewContainer.scss
├── entries
│   └── issue.js
├── lib
│   ├── constants
│   │   └── EndPoints.js
│   ├── records
│   │   ├── Comment.js
│   │   ├── Issue.js
│   │   ├── IssueDetailManager.js
│   │   ├── IssueListManager.js
│   │   ├── IssueManager.js
│   │   ├── IssueNewManager.js
│   │   ├── Label.js
│   │   └── User.js
│   └── utils
│       └── nl2br.js
├── reducers
│   ├── issue.js
│   └── issueApp.js
└── stores
    └── configureIssueStore.js

10 directories, 42 files

Fluxフレームワークとして、Reduxを使うので、Reduxの構成に沿ったディレクトリ構成になっています。

以下の図がReduxの構成を理解しやすいです。(出典: UNIDIRECTIONAL USER INTERFACE ARCHITECTURES)

Redux

  • actions
    • ReduxのActionsにあたる部分を担当します。今回はこの層にAPI通信やビジネスロジックもおいているため、主要なロジックはここに集まります。
  • components
    • Reactコンポーネントと、CSS Modulesで使用するsassのファイルを配置しています。
    • Presentational and Container Componentsなどの設計議論をふまえて、このフォルダ配下はActionを発行せず、"受け取ったpropsからの描画" "ユーザ入力を受け付ける" の2点にフォーカスします。
  • containers
    • Reactコンポーネントですが、components配下とは逆に極力画面要素の描画はさけ、"components配下で発生したイベントのハンドリング" -> "Actionのコール" に専念します。
  • entries
    • webpackはこのファイルを起点にビルドをかけます。ここにルーティングに関する記述があります。
  • lib/records
    • immutable.jsのRecord型を使い、独自にモデルクラスを作成しています。
  • reducers
    • ReduxのReducersにあたる部分です。実際にStateを更新します。
  • stores
    • stateをまとめて保持する層です。初期設定項目のみで、開発時に触ることはないはずです。

:notebook: 簡単にソースを追いながら概要説明 :notebook:

サンプルソースを追えるようになるために、詳細画面のタイトル編集の部分を例にソースをおってみます。

スクリーンショット 2016-06-09 18.40.59.png

src/entries/issue.js

初期設定が多いですが、開発時にはルーティングに関する以下記述が重要です。

reactjs/react-router-reduxreactjs/react-routerを使っています。

/をルートとして、/new/:idのパスがあることが分かります。それぞれのパスにアクセスされた時に対応するcomponentがマウントされます。

src/entries/issue.js
ReactDOM.render(
  <Provider store={store}>
    <Router history={history}>
      <Route path="/" component={IssueContainer}>
        <IndexRoute component={IssueListContainer} />
        <Route path="/new" component={IssueNewContainer} />
        <Route path="/:id" component={IssueDetailContainer} />
      </Route>
    </Router>
  </Provider>,
  document.getElementById('content')
)

src/containers/IssueDetailContainer.js

詳細画面の画面を作成しています。

renderメソッドで必要なコンポーネントを描画しています。

src/containers/IssueDetailContainer.js
render() {
  const { issueDetail, issueDetailManager } = this.props
  return (
    <div className={styles.base}>
      <Link to="/">List Page</Link>
      <Loader loaded={!issueDetailManager.loading}>
        <IssueDetailHeader
          issue={issueDetail}
          isTitleEditing={issueDetailManager.isTitleEditing}
          onClickTitleEdit={this.onClickTitleEdit.bind(this)}
          onClickTitleSave={this.onClickTitleSave.bind(this)}
        />
        <div className={styles.main}>
          <IssueDescription
            issue={issueDetail}
          />
          <IssueCommentList
            comments={issueDetail.comments}
            onClickSave={this.onClickCommentSave.bind(this)}
            onClickDelete={this.onClickCommentDelete.bind(this)}
          />
          <IssueCommentForm
            issue={issueDetail}
            onClickComment={this.onClickCommentSave.bind(this)}
            onClickChangeStatus={this.onClickChangeStatus.bind(this)}
          />
        </div>
      </Loader>
    </div>
  )
}

例えばタイトルの編集後、Saveボタンを押したタイミングでは、このコンポーネント内のonClickTitleSaveメソッドがコールバックされます。

src/containers/IssueDetailContainer.js
onClickTitleSave(issue) {
  this.props.changeTitleEditing(false)
  this.props.updateIssue(issue)
}

あわせて、IssueCommentFormの方も見てみす。

Saveの文言を囲むdivタグに、onClickのコールバックが設定され、onClickTitleSaveメソッドをコールしています。

onClickTitleSave は編集中の状態から新たに Issue モデルを作成して、上位の階層にthis.props.onClickTitleSaveコールバックで引き渡しています。

src/components/IssueDetailHeader.js
onClickTitleSave() {
  const newIssue = this.props.issue.set('title', this.state.title)
  this.props.onClickTitleSave(newIssue)
}

render() {
  const { issue } = this.props
  return (
    <div styleName="base">
      <div styleName="title-wrapper">
        { this.props.isTitleEditing ? (<div>
            <div styleName="title">
              <input
                type="text"
                value={this.state.title}
                onChange={this.onChangeTitle.bind(this)}
              />
              <div
                styleName="edit-button"
                onClick={this.onClickTitleSave.bind(this)}
              >
                Save
              </div>
            </div>
          </div>) : (
          <span>
            <div styleName="title">
              {issue.title}
            </div>
            <div styleName="edit-button" onClick={this.props.onClickTitleEdit}>
              Edit
            </div>
          </span>
          )
        }
      </div>
      // 以下略
}

ここでthis.props.XXXsrc/actions/issueDetail.js#XXXをコールされます。以下の部分でActionとこのコンポーネントが接続されているためです。

src/containers/IssueDetailContainer.js
const mapDispatchToProps = (dispatch) => {
  return bindActionCreators({
    findIssueDetail,
    addComment,
    updateComment,
    deleteComment,
    changeTitleEditing,
    updateIssue,
  }, dispatch)
}

src/actions/issueDetail.js

Actionの updateIssue メソッドを見ていきます。Issue更新のAPIリクエストを投げ、画面を更新する部分です。

  • dispatch(setIssueDetail(issueDetail)) -> setIssueDetailのアクションをdispatchしている、ここからReducerに処理が続きます
  • updateIssueRequest(issueDetail) -> Issue更新のAPIリクエストです
src/actions/issueDetail.js
async function updateIssueRequest(issue) {
  const data = {
    issue: {
      id: issue.id,
      title: issue.title,
      status: issue.status,
    }
  }

  const response = await $.ajax({
    url: `${END_POINTS.ISSUES}/${issue.id}`,
    method: 'PATCH',
    data,
    timeout: 100000,
  })

  return initIssueDetail(response)
}

function setIssueDetail(issueDetail) {
  return {
    type: Actions.SET_ISSUE_DETAIL,
    issueDetail,
  }
}

export function updateIssue(issueDetail) {
  return async(dispatch) => {
    dispatch(setIssueDetail(issueDetail))
    try {
      await updateIssueRequest(issueDetail)
    } catch (error) {
      console.log("error", error)
    }
  }
}

redux-thunkによる非同期処理

return async(dispatch) => {}で囲われているのは、Reduxで非同期処理を扱うredux-thunk middle wareの作法です。Timerや通信処理を含む場合に必要です。

async/await

ES2015のstage-3の機能です。Promiseよりもよりシンプルに非同期処理を書くことができます。エラー処理はtry ~ catchで行い、Ajaxリクエストの場合、ステータス200以外の場合にcatchされます。

src/reducers/issue.js

dispatchされたActionは全てのReducerに対して送信されます。dispatchした時のキー(今回だとtype)で分岐しながら、処理するActionを補足します。

ここではIssueDetailActions.SET_ISSUE_DETAIL の周辺を見ます。

typeと共に渡された、issueDetail を返しているだけですね。これでstateが更新され、更新をトリガーに画面の必要な部分だけが再描画されます。

function issueDetail(state = new Issue(), action) {
  switch (action.type) {
    case IssueDetailActions.SET_ISSUE_DETAIL:
      return action.issueDetail
    case IssueDetailActions.SET_COMMENTS:
      return state.set('comments', action.comments)
    default:
      break // do nothing
  }
  return state
}

src/lib/records/Issue.js

ここまでの一連の流れで利用されている issueDetail の実体は、src/lib/records/Issue.jsで独自に定義されたモデルクラスで、今のWantedlyのスタックで非常に重要な部分を占めています。

Component間のやりとりや、Action -> Reducerの間の受け渡しをこのモデルに統一することで、見通しをよくするだけではなく、キー名のtypoなどにも気付きやすくなります。

以下のブログ記事はWantedlyの利用方法に近い例を紹介しています。
How to use Immutable.js Records with React and Redux

fromJSIssue.fromJS(someObject)で実行可能で、APIレスポンスなどをこのモデルに変換するために利用します。

また、モデルクラスのため、validationのロジックもこの中に寄せています。

src/lib/records/Issue.js
const _Issue = Record({
  id: null,
  title: '',
  status: STATE.CLOSE,
  comment_count: null,
  created: '',
  updated: '',
  comments: new List(),
  content: '',
  assignee: new User(),
})

export default class Issue extends _Issue {
  static fromJS(issue = {}) {
    let comments = new List()

    if (issue.comments) {
      comments = new List(issue.comments.map((comment) => {
        return Comment.fromJS(comment)
      }))
    }

    return (new this).merge({
      id: parseInt(issue.id),
      title: issue.title,
      status: issue.status,
      comment_count: issue.comment_count,
      created: issue.created,
      updated: issue.updated,
      content: issue.content,
      comments,
      assignee: issue.assignee ? User.fromJS(issue.assignee) : new User(),
    })
  }

  isValidTitle() {
    return this.title.length > 0
  }

  isValidContent() {
    return this.content.length > 0
  }
}

:muscle: 演習 :muscle:

課題1: 一覧画面にコメント数を表示するカラムを追加しましょう

  • 表示要素はsrc/lib/records/Issue.jsに追加してください
  • APIからはcomment_count というキー名でデータが返ってきています
  • 併せてsrc/components/IssueListItem.scssを触って少しスタイルを調整してみましょう

comment-count.png

課題2: 一覧画面にアサインとラベルによる絞込機能を追加しましょう

  • 既にOpen/Closeのステータスに絞込の実装はされているので、最終的にsrc/containers/IssueListContainer.jssearchメソッドにつなぎ込むことを目指します。

assign-label.gif

課題3: 詳細画面に投稿済みコメントの編集機能と削除機能を追加しましょう

  • コメントの編集中を判定するための状態をもつ必要があります
    • そのフラグを見て、Viewの中でinput/表示を切り替えます
    • コメントの投稿はsrc/components/IssueCommentForm.js を真似てみてください
    • src/actions/issueDetail.jsupdateCommentメソッドをコールすることを目指します。
  • 削除機能はAPIアクセス部分が未実装なので、${END_POINTS.ISSUES}/${issue.id}/comments/${comment.id}DELETEメソッドを送るアクセス部分を作成します

comment-edit.gif

課題4: 詳細画面の新規コメント投稿時に"ユーザ名"と"コメント内容"のブランクチェックを実装しましょう

  • src/actions/issueDetail.jspostCommentRequestメソッドの中で、サーバサイドからのエラーを補足しましょう。
    • エラー文言などはIssueDetailManagerに置くのが良いと思います
  • クライアントサイドのバリデーション処理も追加しましょう。
    • バリデーションのロジックはsrc/lib/records/Comment.jsに書きましょう

課題5: 詳細画面に"ラベル追加機能"と"アサイン追加機能"を追加しましょう

  • react-modalを使ってモーダル画面を追加します。
    • 一覧画面のアサインとラベルの絞込が参考になります。
    • 最終的には

add-assign-label.gif

:coffee: 物足りない方のための追加演習 :coffee:

一覧での検索状態を保持できるようにしましょう

  • 一覧 -> 詳細 -> 一覧のような遷移をすると、一覧の絞込条件がクリアされてしまいます。
    • paramsを保持することで、復帰できるようにしましょう。

一覧の並び換えを実装してみましょう

  • APIは実装済みです

ラベルの作成画面を実装してみましょう

  • ここまでの内容をふまえて、ラベルの作成・編集画面を実装してみましょう

:thumbsup: 以上です :thumbsup:

いかがでしたでしょうか、React + Reduxなスタックでの実装の参考になれば幸いです :bow:

:smile: Contributors :smile:

スクリーンショット 2016-06-09 18.42.16.png

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away