React + Reduxを使ったWebアプリケーション開発速習会@Wantedly のための資料です。
WantedlyではSingle Page Applicationなどより複雑な構成にも耐えられるよう、React + Reduxを中心としたWebフロントエンドの技術スタックを導入しました。
- ES2015
- React
- Redux
- Immutable.js
- CSS Modules
- webpack
導入の経緯などは以前発表した の資料を参照ください!
今回はこれらのスタックを使って実際にSingle Page Application構成のアプリケーションをハンズオンで作成します。
開発の準備
開発環境
npmライブラリを使用するため、開発マシンにnode.jsがインストール済みであることを前提としています。
サンプルはv5.1.1で動作確認しています。
% node -v
v5.1.1
今回使うソース
shimpeiws/react-redux-sokushu-practice
GitHub上の のソースをgit cloneした後に、ディレクトリ直下で以下を実行してください。
npm install
npm run webpack:serve
もうひとつターミナルのペインを開き、ディレクトリ直下に移動し、以下を実行してください。
npm run serve
localhost:8000
にアクセスして画面が表示されれば準備は完了です。
完成したサンプル
演習の内容を実装済みのリポジトリです
演習でつまったときや、動作の確認に使ってください。
起動方法は同じですが、こちらはlocalhost:9000
で起動するようになっています。
今回の課題内容
GitHubのIssue機能を模したアプリケーションを作成します。
"一覧画面" "詳細画面" "Issue新規作成画面" の3画面で構成されています。
一覧画面
Issueのデータを一覧で表示しています。行をクリックすることで詳細画面に遷移します。
Issueのstatusなど各種フィルタが実装されます。
詳細画面
Issueに対してアサインやラベル、コメントを追加することができます。
新規Issue作成画面
Issueを新規追加することができます、titleとcontentは必須欄です。
使用するAPI
shimpeiws/react-redux-sokushu-api
演習中は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)を使っています
- public配下を
- 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)
- actions
- ReduxのActionsにあたる部分を担当します。今回はこの層にAPI通信やビジネスロジックもおいているため、主要なロジックはここに集まります。
- components
- Reactコンポーネントと、CSS Modulesで使用するsassのファイルを配置しています。
- Presentational and Container Componentsなどの設計議論をふまえて、このフォルダ配下はActionを発行せず、"受け取ったpropsからの描画" "ユーザ入力を受け付ける" の2点にフォーカスします。
- containers
- Reactコンポーネントですが、
components
配下とは逆に極力画面要素の描画はさけ、"components配下で発生したイベントのハンドリング" -> "Actionのコール" に専念します。
- Reactコンポーネントですが、
- entries
- webpackはこのファイルを起点にビルドをかけます。ここにルーティングに関する記述があります。
- lib/records
- immutable.jsのRecord型を使い、独自にモデルクラスを作成しています。
- reducers
- ReduxのReducersにあたる部分です。実際にStateを更新します。
- stores
- stateをまとめて保持する層です。初期設定項目のみで、開発時に触ることはないはずです。
簡単にソースを追いながら概要説明
サンプルソースを追えるようになるために、詳細画面のタイトル編集の部分を例にソースをおってみます。
src/entries/issue.js
初期設定が多いですが、開発時にはルーティングに関する以下記述が重要です。
reactjs/react-router-redux と reactjs/react-routerを使っています。
/
をルートとして、/new
と/:id
のパスがあることが分かります。それぞれのパスにアクセスされた時に対応するcomponent
がマウントされます。
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メソッドで必要なコンポーネントを描画しています。
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
メソッドがコールバックされます。
onClickTitleSave(issue) {
this.props.changeTitleEditing(false)
this.props.updateIssue(issue)
}
あわせて、IssueCommentForm
の方も見てみす。
Save
の文言を囲むdiv
タグに、onClick
のコールバックが設定され、onClickTitleSave
メソッドをコールしています。
onClickTitleSave
は編集中の状態から新たに Issue
モデルを作成して、上位の階層にthis.props.onClickTitleSave
コールバックで引き渡しています。
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.XXX
はsrc/actions/issueDetail.js#XXX
をコールされます。以下の部分でActionとこのコンポーネントが接続されているためです。
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リクエストです
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
fromJS
はIssue.fromJS(someObject)
で実行可能で、APIレスポンスなどをこのモデルに変換するために利用します。
また、モデルクラスのため、validationのロジックもこの中に寄せています。
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
}
}
演習
課題1: 一覧画面にコメント数を表示するカラムを追加しましょう
- 表示要素は
src/lib/records/Issue.js
に追加してください - APIからは
comment_count
というキー名でデータが返ってきています - 併せて
src/components/IssueListItem.scss
を触って少しスタイルを調整してみましょう
課題2: 一覧画面にアサインとラベルによる絞込機能を追加しましょう
- 既にOpen/Closeのステータスに絞込の実装はされているので、最終的に
src/containers/IssueListContainer.js
のsearch
メソッドにつなぎ込むことを目指します。
課題3: 詳細画面に投稿済みコメントの編集機能と削除機能を追加しましょう
- コメントの編集中を判定するための状態をもつ必要があります
- そのフラグを見て、Viewの中でinput/表示を切り替えます
- コメントの投稿は
src/components/IssueCommentForm.js
を真似てみてください -
src/actions/issueDetail.js
のupdateComment
メソッドをコールすることを目指します。
- 削除機能はAPIアクセス部分が未実装なので、
${END_POINTS.ISSUES}/${issue.id}/comments/${comment.id}
へDELETE
メソッドを送るアクセス部分を作成します
課題4: 詳細画面の新規コメント投稿時に"ユーザ名"と"コメント内容"のブランクチェックを実装しましょう
-
src/actions/issueDetail.js
のpostCommentRequest
メソッドの中で、サーバサイドからのエラーを補足しましょう。- エラー文言などはIssueDetailManagerに置くのが良いと思います
- クライアントサイドのバリデーション処理も追加しましょう。
- バリデーションのロジックは
src/lib/records/Comment.js
に書きましょう
- バリデーションのロジックは
課題5: 詳細画面に"ラベル追加機能"と"アサイン追加機能"を追加しましょう
-
react-modal
を使ってモーダル画面を追加します。- 一覧画面のアサインとラベルの絞込が参考になります。
- 最終的には
物足りない方のための追加演習
一覧での検索状態を保持できるようにしましょう
- 一覧 -> 詳細 -> 一覧のような遷移をすると、一覧の絞込条件がクリアされてしまいます。
- paramsを保持することで、復帰できるようにしましょう。
一覧の並び換えを実装してみましょう
- APIは実装済みです
ラベルの作成画面を実装してみましょう
- ここまでの内容をふまえて、ラベルの作成・編集画面を実装してみましょう
以上です
いかがでしたでしょうか、React + Reduxなスタックでの実装の参考になれば幸いです
Contributors
- API & CSS -> @mamoru0217
- Client -> @kawasin73
- supervisor -> @KentoMoriwaki