React のデータ管理をmobxにしようと思い。練習用に。
incrementとかのとても分かりやすいサンプルはたくさんあるので、もうちょいアプリケーションっぽいものを。
qiitaのAPIから記事データを取得して、記事情報/お気に入り情報をobservable。
成果物
デモ
https://mobx-practice-v1.netlify.com/
コード
https://github.com/nakadoribooks/mobx-practice/releases/tag/v0.0.1
流れ
データをフェッチして
itemList と likedList を computed で参照。
like でお気に入り登録、isLiked を observable。
環境
React / Mobx / StyledComponent
今回StyledComponentはあんま関係ないので、React/Mobxの部分だけをご紹介。
デコレータの扱いが微妙でハマった。
結果、こんな感じのpackage.json と webpack.config
{
"name": "mobx",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"dependencies": {
"babel-eslint": "^8.2.1",
"babel-plugin-transform-class-properties": "^6.24.1",
"babel-plugin-transform-decorators-legacy": "^1.3.4",
"babel-plugin-transform-runtime": "^6.23.0",
"babel-preset-es2015": "^6.24.1",
"babel-preset-mobx": "^1.0.2",
"babel-preset-stage-1": "^6.24.1",
"mobx": "^3.5.1",
"mobx-react": "^4.4.2",
"netlify-cli": "^1.2.2",
"react": "^16.2.0",
"react-dom": "^16.2.0",
"react-router-dom": "^4.2.2",
"styled-components": "^3.1.6",
"superagent": "^3.8.2"
},
"devDependencies": {
"babel-core": "^6.26.0",
"babel-loader": "^7.1.2",
"babel-preset-env": "^1.6.1",
"babel-preset-react": "^6.24.1",
"eslint": "^4.18.0",
"eslint-plugin-react": "^7.6.1",
"webpack": "^3.11.0",
"webpack-dev-server": "^2.11.1"
},
"babel": {
"plugins": [
"transform-decorators-legacy",
"transform-class-properties",
"transform-runtime"
]
},
"scripts": {
"start": "webpack-dev-server --history-api-fallback",
"build": "webpack -d && cp dist/index.html dist/404.html"
}
}
const path = require('path');
module.exports = {
entry: path.resolve(__dirname, 'src/app.js'),
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'app.js'
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
loader: 'babel-loader',
options: {
plugins: [
'transform-decorators-legacy',
'transform-class-properties'
],
presets: ['react', 'env']
}
}
]
},
devServer: {
contentBase: path.resolve(__dirname, 'dist'),
port: 3000
},
devtool: 'inline-source-map'
};
実装
実装見ていきます。
ファイル構成
- component/item/index (記事一覧画面)
- component/like/index (いいねした記事画面)
- component/mypage/index (マイページ)
- store/entity/item (記事情報)
- store/ItemStore (記事ストア)
- app (ストアをコンポーネントにアサイン)
ItemStore
主に以下3つ。
- fetch データ取ってくる
- itemList (computed)
- likedList (computed)
import {observable, action, computed} from 'mobx';
import superagent from 'superagent';
import Item from './entity/Item';
import Config from '../Config';
class ItemStore{
_loadedData = false
@observable _data = null;
@observable selectedItem = null
@observable selectedItemLiked = null
@computed get itemList() { return this._data || [];}
@computed get likedList() {
return this.itemList
.filter( elem => { return elem.isLiked; })
.sort((e1, e2)=>{ return e1.editedAt < e2.editedAt; });
}
@action select(itemId){
this.selectedItem = this.find(itemId);
}
@action selectLiked(itemId){
this.selectedItemLiked = this.find(itemId);
}
find(id) {
if(id == null){ return null; }
return this.itemList.find( item => { return (item.data.id == id); });
}
fetchIfneeded(){
if(this._loadedData){ return; }
this._loadedData = true;
this._fetch();
}
_fetch(){
superagent
.get(`${Config.apiEndpint}/items`).set('Authorization', `Bearer ${Config.token}`)
.end((error, res) => {
if (res.status != 200) {
return;
}
const json = JSON.parse(res.text);
const data = json.map((item) => { return new Item(item); });
this._data = data;
});
}
}
export default ItemStore;
Item
記事一個分のデータ。
- お気に入り状態の保持
import {observable, action} from 'mobx';
class Item{
constructor(data){
this.data = data;
}
editedAt = new Date()
@observable isLiked = false
@action like(){
this.isLiked = true;
this.editedAt = new Date();
}
@action unLike(){
this.isLiked = false;
this.editedAt = new Date();
}
}
export default Item;
App
stores(ストアの集合) を Providor(mobxとreactつなぐやつ?)に渡してその中に各コンポーネント(画面)を定義
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter, Route} from 'react-router-dom';
import {Item, Like, Mypage} from './components/';
import { Provider } from 'mobx-react';
import {ItemStore,UserStore} from './store';
import {Menu, MenuItem} from './style/Menu';
const stores = {
itemStore: new ItemStore()
, userStore: new UserStore()
};
class App extends React.Component {
/* 〜 ルーティングとか、略 〜 */
render() {
return (
<BrowserRouter>
<div>
<Menu state={this.state}>
<MenuItem to="/item" onClick={this.onClickMenu.bind(this)} data-index={1}>New</MenuItem>
<MenuItem to="/like" onClick={this.onClickMenu.bind(this)} data-index={2}>Like</MenuItem>
<MenuItem to="/mypage" onClick={this.onClickMenu.bind(this)} data-index={3}>MyPage</MenuItem>
</Menu>
<Provider {...stores}>
<div>
<Route exact path="/" component={Item} />
<Route path="/item/:id?" component={Item} />
<Route path="/like/:id?" component={Like} />
<Route path="/mypage" component={Mypage} />
</div>
</Provider>
</div>
</BrowserRouter>
);
}
}
ReactDOM.render(
<App />,
document.getElementById('app')
);
Item 画面
記事一覧画面。
Providerに渡されてるitemStore を使う宣言。
@inject('itemStore')
@observer
- 画面全体のdidMount で fetchする
- componentWillReceiveProps で画面遷移を拾って対象の記事を選択状態にする
- お気に入りボタンタップ(onClickLike)で like / unlike
import React from 'react';
import PropTypes from 'prop-types';
import {inject, observer} from 'mobx-react';
import {
Loader
, ListContents, ListItemWrapper, ListItemRow, ListItemLink, ListLikeImage
, ContentsWrapper, ContentsInner, Title, FakeTitle, LikeButton} from '../../style/Common';
/**
* 右の記事内容の画面
*/
@inject('itemStore')
@observer
class Contents extends React.Component {
/* 〜 略 〜 */
onClickLike(){
const item = this.props.itemStore.selectedItem;
if(item.isLiked){
item.unLike();
}else{
item.like();
}
}
render() {
const item = this.props.itemStore.selectedItem;
return (
/* 〜 略 〜 */
);
}
}
/**
* 左のリスト
*/
@inject('itemStore')
@observer
class List extends React.Component {
/* 〜 略 〜 */
}
/**
* ItemComponent
* メイン
*/
@inject('itemStore')
@observer
class ItemComponent extends React.Component {
/* 〜 略 〜 */
componentDidMount(){
this.props.itemStore.fetchIfneeded();
}
componentWillReceiveProps(nextProps){
const id = nextProps.match.params.id;
this.props.itemStore.select(id);
}
render() {
return (
<div>
<List />
<Contents />
</div>
);
}
}
export default ItemComponent;
参考
その他
とりあえずいい感じ。気軽さが心地よい。
react / mobx / styledcomponent
次のサービスはいったんこの組み合わせで行く。