- 投稿日:2019-07-04T23:23:07+09:00
nodejs + mysql + React + ReduxでCRUDアプリを作る Part1
概要
シンプルなCRUD(create, read, update, delete)アプリをデータベースはmysql, フロントエンドはReact + Reduxで作ってみます.
このPart1ではmysqlの設定, node.jsでAPIサーバーの作成まで行います.mysqlのインストール, 接続
まずmysqlをインストールします.
$ brew update $ brew install mysqlインストールが終わったら内容を見てみます.
$ brew info mysql次にデータベースを起動します. 起動は
mysql.server start(停止はmysql.server stop)$ mysql.server start Starting MySQL . SUCCESS!パスワードを設定しておきます.
$ mysql_secure_installation聞かれることは基本的にYesで答えます. パスワードは覚えておきましょう.
設定が終わったら接続します.$ mysql -uroot -p Enter password:[設定したパスワード]接続は
exitで抜けれます.mysqlでDB, テーブルの作成
mysqlに接続した状態でまずDBを作成します.
mysql> CREATE DATABASE sample;DBの作成結果を確認します.
mysql> show databases;作成したDBを使用可能にします.
mysql> use sample;次にテーブルを作成します.
idカラムにはauto_incrementを設定しておきます.
nameとstatusにはnot nullを設定します.
nameはuniqueにしておきます.mysql> create table user (id int auto_increment, name varchar(10) not null unique, status varchar(10) not null,テーブルの作成結果を確認します.
mysql> show tables; +------------------+ | Tables_in_sample | +------------------+ | user | +------------------+ 1 row in set (0.01 sec)テーブルの定義を確認する
mysql> desc user; +--------+-------------+------+-----+---------+----------------+ | Field | Type | Null | Key | Default | Extra | +--------+-------------+------+-----+---------+----------------+ | id | int(11) | NO | MUL | NULL | auto_increment | | name | varchar(10) | NO | PRI | NULL | | | status | varchar(10) | NO | | NULL | | +--------+-------------+------+-----+---------+----------------+ 3 rows in set (0.01 sec)ユーザーを作成します.
nodeがユーザー, localhostがホストになります.
パスワードは後で使うので覚えておきましょう.mysql> create user 'node'@'localhost' identified with mysql_native_password by 'パスワード';ユーザーの作成結果を確認します.
mysql> SHOW GRANTS for 'node'@'localhost';参照可能テーブルを指定します.
mysql> GRANT ALL ON sample.* to node@localhost;権限の付与結果を確認します.
mysql> SHOW GRANTS for 'node'@'localhost';試しにサンプルデータを作成してみます.
mysql> insert into user(name, status) values('Katsuomi', 'student');確認してみます.
mysql> select * from staff; +----+----------+---------+ | id | name | status | +----+----------+---------+ | 1 | Katsuomi | student | +----+----------+---------+ 1 row in set (0.01 sec)もう1人作ってみます
mysql> insert into user(name, status) values('Junki', 'student');確認します.
mysql> select * from user; +----+----------+---------+ | id | name | status | +----+----------+---------+ | 1 | Katsuomi | student | | 2 | Junki | student | +----+----------+---------+ 2 rows in set (0.00 sec)無事にidがauto_incrementされていることを確認しました.
APIサーバーの作成
適当にディレクトリを作って
server.jsを作り, そこに書いていきます.$ mkdir crud-node & cd crud-node $ npm init -y $ npm i mysql express body-parserserver.jsconst express = require('express'); const mysql = require('mysql'); const bodyParser = require('body-parser'); const cors = require('cors')({origin: true}); const app = express(); app.use(bodyParser.json()); app.use(cors); const client = mysql.createConnection({ host: 'localhost', user: 'node', password: 'HashSignBack1484?_!', port : 3306, database: 'sample' }); client.connect(function (err) { if (err) { console.error('error connecting: ' + err.stack); return; } console.log('connected as id ' + client.threadId); }); // read app.get('/user', (req, res) => { client.query('SELECT * from user;', (err, rows, fields) => { if (err) throw err; res.send(rows); }); }); // create app.post('/user/create', (req, res) => { const name = req.body.name; const status = req.body.status; client.query('INSERT INTO user SET ?', {name: name, status: status}, (err, result) => { if (err) throw err; res.send(result); }) }); // update app.put('/user/update', (req, res) => { const id = req.body.id; const status = req.body.status; client.query('UPDATE user SET status = ? WHERE id = ?', [status, id], (err, result) => { if (err) throw err; client.query('SELECT * from user;', (err, rows, fields) => { if (err) throw err; res.send(rows); }); }) }); // delete app.delete('/user/delete', (req, res) => { const id = req.body.id; client.query(`DELETE FROM user WHERE id = ?`, [id], (err, result) => { if (err) throw err; client.query('SELECT * from user;', (err, rows, fields) => { if (err) throw err; res.send(rows); }); }); }); app.listen(3001, () => console.log('Listening on port 3001!'))それではcurlでテストしていきましょう.
作成
$ curl -X POST -H "Content-Type:application/json" http://localhost:3000/user/create -d '{"name":"taro", "status": "adult"}'更新
$ curl -X PUT -H "Content-Type:application/json" http://localhost:3000/user/update -d '{"name":"taro", "status": "student"}'閲覧
curl http://localhost:3000/user削除
$ curl -X DELETE -H "Content-Type:application/json" http://localhost:3000/user/delete -d '{"name":"taro"}'最後に
次回はReact + Reduxでフロントエンドを作っていきます.
Happy Hacking
!
- 投稿日:2019-07-04T22:23:57+09:00
redux-form (5) - Initialize From State
redux-form (1) - Simple Form Example
redux-form (2) - Synchronous Validation Example
redux-form (3) - Field-Level Validation Example
redux-form (4) - Submit Validation Example
redux-form (5) - Initialize From State
ReactでForm componentを作るときに、とても便利なredux-formの説明です。
redux-formの概説についてはまず以下の記事を参考にしてください。
redux-form (1) - Simple Form Example
Initialize From State
Initialize From State - Getting Started With redux-form
redux-formでは、decorated form component(wrapped component)のform stateを初期化するために、initialValuesを用います。initialValuesは、例えばサーバから取得した値を、React Redux の connect()を使って、mapStateToPropsでRedux state をinitialValues propに変換して、decorated form componentに渡すようにします。
initialValuesでformを初期化できるのは、デフォルトで1回のみです。
decorated form componentはreset() propを受け取りますが、reset()を実行することでformをinitialValuesに戻すことができます。
以下が、reducerとaction creatorのソースです。
src/account.js// Quack! This is a duck. https://github.com/erikras/ducks-modular-redux const LOAD = 'redux-form-examples/account/LOAD' const reducer = (state = {}, action) => { switch (action.type) { case LOAD: return { data: action.data } default: return state } } /** * Simulates data loaded into this reducer from somewhere */ export const load = data => ({ type: LOAD, data }) export default reducer以下がredux-formのサンプルです。ポイントの説明を下にまとめてあります。
src/InitializeFromStateForm.jsimport React from 'react' import { connect } from 'react-redux' import { Field, reduxForm } from 'redux-form' import { load as loadAccount } from './account' const data = { // used to populate "account" reducer when "Load" is clicked firstName: 'Jane', lastName: 'Doe', age: '42', anniversaryDate: '2018-08-22', sex: 'female', employed: true, favoriteColor: 'Blue', bio: 'Born to write amazing Redux code.' } const colors = ['Red', 'Orange', 'Yellow', 'Green', 'Blue', 'Indigo', 'Violet'] let InitializeFromStateForm = props => { const { handleSubmit, load, pristine, reset, submitting } = props return ( <form onSubmit={handleSubmit}> <div> <button type="button" onClick={() => load(data)}> Load Account </button> </div> <div> <label>First Name</label> <div> <Field name="firstName" component="input" type="text" placeholder="First Name" /> </div> </div> <div> <label>Last Name</label> <div> <Field name="lastName" component="input" type="text" placeholder="Last Name" /> </div> </div> <div> <label>Age</label> <div> <Field name="age" component="input" type="number" placeholder="Age" /> </div> </div> <div> <label>Anniversary Date</label> <div> <Field name="anniversaryDate" component="input" type="date" /> </div> </div> <div> <label>Sex</label> <div> <label> <Field name="sex" component="input" type="radio" value="male" />{' '} Male </label> <label> <Field name="sex" component="input" type="radio" value="female" />{' '} Female </label> </div> </div> <div> <label>Favorite Color</label> <div> <Field name="favoriteColor" component="select"> <option value="">Select a color...</option> {colors.map(colorOption => ( <option value={colorOption} key={colorOption}> {colorOption} </option> ))} </Field> </div> </div> <div> <label htmlFor="employed">Employed</label> <div> <Field name="employed" id="employed" component="input" type="checkbox" /> </div> </div> <div> <label>Bio</label> <div> <Field name="bio" component="textarea" /> </div> </div> <div> <button type="submit" disabled={pristine || submitting}> Submit </button> <button type="button" disabled={pristine || submitting} onClick={reset}> Undo Changes </button> </div> </form> ) } // Decorate with reduxForm(). It will read the initialValues prop provided by connect() InitializeFromStateForm = reduxForm({ form: 'initializeFromState' // a unique identifier for this form })(InitializeFromStateForm) // You have to connect() to any reducers that you wish to connect to yourself InitializeFromStateForm = connect( state => ({ initialValues: state.account.data // pull initial values from account reducer }), { load: loadAccount } // bind account loading action creator )(InitializeFromStateForm) export default InitializeFromStateFormReact-Reduxのconnect()関数について少し説明します。
mapStateToProps で initialValues propが設定されています。initialValues は reduxForm が処理します。initialValues は { field1: 'value1', field2: 'value2' } の形をしており、componentWillMount() において form を初期化します。
initialValuesはここの例のようにpropで受け渡す以外に、reduxForm() のconfig parameterで受け渡す方法もあります。mapDispatchToProps は object を指定することができます。この場合、objectの各fieldがaction creatorとなり、 React-Redux は dispatchをaction creatorにbindします。
connect() -React ReduxInitializeFromStateForm = connect( state => ({ // mapStateToProps initialValues: state.account.data }), { load: loadAccount } // mapDispatchToProps )(InitializeFromStateForm)以下はindex.jsですが、オリジナルのものを最小化してあります。
src/index.jsimport React from 'react' import ReactDOM from 'react-dom' import { Provider } from 'react-redux' import { createStore, combineReducers } from 'redux' import { reducer as reduxFormReducer } from 'redux-form' import account from './account' const dest = document.getElementById('content') const reducer = combineReducers({ account, form: reduxFormReducer // mounted under "form" }) const store = createStore(reducer) const showResults = values => new Promise(resolve => { setTimeout(() => { // simulate server latency window.alert(`You submitted:\n\n${JSON.stringify(values, null, 2)}`) resolve() }, 500) }) let render = () => { const InitializeFromStateForm = require('./InitializeFromStateForm').default ReactDOM.hydrate( <Provider store={store}> <h2>Form</h2> <InitializeFromStateForm onSubmit={showResults} /> </Provider>, dest ) } render()実行画面
初期画面です
「Load Account」ボタンでformを初期化します。最初の一回目だけ有効です。初期状態を変更してから、再度初期状態に戻すためには、UndoChangesボタンでreset()を発行する必要があります。
submitボタンを押します。
今回は以上です。
- 投稿日:2019-07-04T14:01:14+09:00
VS Codeで開発環境を整える
参考URL:https://qiita.com/gumiTECH/items/4b82e4e3757f226df6b0
・node jsをインストールしておき npmコマンドが使えるようにしておく。
・VS Codeをインストールしておく。1. Reactの雛形アプリケーションを作成
以下のコマンドでreact関連のパッケージをインストールする。
npm install -g create-react-appその後、任意のフォルダに移動し、以下のコマンドを実行(以下は、[react-sample]というアプリ名の例) ※そこそこ時間かかる。
create-react-app react-sample
以下のコマンドを実行する。
cd react-sample
npm starthttp://localhost:3000/
でアプリケーションのページを開くことができる。
- 投稿日:2019-07-04T12:33:00+09:00
React+ReduxにRedux-Sagaを導入して非同期処理(axios)をさせる手順
ソースコード
https://github.com/tontoko/react-redux-saga-rails↓こちらの記事を参考にまずReact+Rails(API)の環境を整えました。
Ruby on Rails+ReactでCRUDを実装してみた
https://qiita.com/yoshimo123/items/9aa8dae1d40d523d7e5d非常に分かりやすいです。
Reduxの導入
sudo npm install redux react-redux --saveActionCreator
crud-front/src/Actions/actions.jsexport default { create: (data) => { return { type: 'CREATE', data } }, update: (id, data) => { return { type: 'UPDATE', id, data } }, delete: (id) => { return { type: 'DELETE', id } }, init: () => { return { type: 'INIT' } }, }Reducer
crud-front/src/Reducers/productsReducer.js// 中味はまだ空 const initialState = { products: [], isFetching: false, } export default function productReducer(state = initialState, action) { switch (action.type) { case 'CREATE': return Object.assign({}, state, { }) case 'UPDATE': return Object.assign({}, state, { }) case 'DELETE': return Object.assign({}, state, { }) case 'INIT': return Object.assign({}, state, { }) default: return state } }crud-front/src/Reducers/reducers.js// Reducer達を一つに纏める // 今回は必要ないけど。。 import {combineReducers} from 'redux' import productsReducer from './productsReducer' export default combineReducers({ products: productsReducer, });index.js
crud-front/src/index.js// -- 省略 -- import { Provider } from 'react-redux' import { createStore, applyMiddleware } from 'redux' import Reducers from './Reducers/reducers' const store = createStore( Reducers, ) ReactDOM.render( <Provider store={store}> <App /> </Provider>, document.getElementById('root') )MainContainer.js
crud-front/src/Components/MainContainer.js// -- 省略 -- import { connect } from 'react-redux' import Actions from '../Actions/actions' import { bindActionCreators } from 'redux' const mapStateToProps = state => { return state } const mapDispatchToProps = dispatch => { return { init: () => dispatch(Actions.init()), create: (data) => dispatch(Actions.create(data)), update: (id, product) => dispatch(Actions.update(id, product)), delete: (id) => dispatch(Actions.delete(id)), } } //// こっちでもいいかな // const mapDispatchToProps = dispatch => { // return bindActionCreators(Actions, dispatch) // } export default connect(mapStateToProps, mapDispatchToProps)(MainContainer)非同期処理をどこに書くべきか
Redux導入の雛形はできたものの、ここで問題が起きる。
reduxで非同期処理をするいくつかの方法(redux-thunk、redux-saga)
https://qiita.com/muiscript/items/63386fd65c7e9f06f5d4Actionをプレーンに保てテストもしやすい、というRedux-Sagaを試してみます。
Redux-Sagaの導入
Redux-Sagaの概要については以下の記事もわかりやすいです。
redux-sagaで非同期処理と戦う
https://qiita.com/kuy/items/716affc808ebb3e1e8ac【React】 redux-saga でAPIを叩く
https://k-tomoo.hatenablog.com/entry/2018/03/12/151045まずはインストールします。
sudo npm install redux-saga --saveSaga部分をInit処理を例にとって書いていきます。
crud-front/src/Saga/Init.jsimport axios from "axios" import { put, call, takeEvery } from 'redux-saga/effects'; const initAjax = () => axios.get('http://localhost:3001/products') .then((res) => { const data = res.data console.log(data) return { data } }) .catch((error) => { return { error } }) function* initProduct() { // 3. const { data, error } = yield call(initAjax); console.log(data) if (data) { // 4. yield put({ type: "INIT_SUCCEEDED", data }); } else { // todo: エラーハンドリング // 今回はエラー処理は省きます } } // 1.& 2. export default [takeEvery("INIT", initProduct)];パッとみてもよくわからないと思うので順に見ていきます。
takeEveryの第一引数で指定されたアクションがどこかで呼ばれる
そのアクションの完了を待って第二引数が呼ばれる
yield call()で中の関数が実行され、Promiseオブジェクトが帰ってくるまで待つ
yield putで新たにActionをdispatchする
ざっくりこのような流れで非同期処理を実現しています。
function* などを見て戸惑った方は以下の記事がわかりやすいです。
https://qiita.com/kura07/items/cf168a7ea20e8c2554c6
https://qiita.com/kura07/items/d1a57ea64ef5c3de8528Redux-Sagaを組み込む
crud-front/src/index.jsimport createSagaMiddleware from 'redux-saga' import { all } from 'redux-saga/effects' import Init from './Saga/Init' import Create from './Saga/Create' import Update from './Saga/Update' import Delete from './Saga/Delete' // ここで一つにまとめます function* rootSaga() { yield all([ ...Init, ...Create, ...Update, ...Delete, ]) } const sagaMiddleware = createSagaMiddleware() const store = createStore( Reducers, applyMiddleware(sagaMiddleware) ) sagaMiddleware.run(rootSaga) ReactDOM.render( <Provider store={store}> <App /> </Provider>, document.getElementById('root') )呼び出し用、成功した処理用、エラー処理用と分けてやる必要があります。(エラー分は今回は省略)
crud-front/src/Actions/actions.jsexport default { create: (data) => { return { type: 'CREATE', data } }, createSuccess: (data) => { return { type: 'CREATE_SUCCEEDED', data } }, update: (id, data) => { return { type: 'UPDATE', id, data } }, updateSuccess: (id, data) => { return { type: 'UPDATE_SUCCEEDED', id, data } }, delete: (id) => { return { type: 'DELETE', id } }, deleteSuccess: (id) => { return { type: 'DELETE_SUCCEEDED', id } }, init: () => { return { type: 'INIT' } }, initSuccess: (data) => { return { type: "INIT_SUCCEEDED", data} }, }crud-front/src/Reducers/productsReducer.jsimport axios from "axios" const initialState = { products: [], isFetching: false, } export default function productReducer(state = initialState, action) { switch (action.type) { case 'CREATE': return Object.assign({}, state, { isFetching: true, }) case 'CREATE_SUCCEEDED': return Object.assign({}, state, { products: [...state.products, action.data], isFetching: false, }) case 'UPDATE': return Object.assign({}, state, { isFetching: true, }) case 'UPDATE_SUCCEEDED': const updateIndex = state.products.findIndex(x => x.id === action.id) const updatedProductsState = state.products updatedProductsState.splice(updateIndex, 1, action.data) return Object.assign({}, state, { products: updatedProductsState, isFetching: false, }) case 'DELETE': return Object.assign({}, state, { isFetching: true, }) case 'DELETE_SUCCEEDED': const deleteIndex = state.products.findIndex(x => x.id === action.id) const deletedProductsState = state.products deletedProductsState.splice(deleteIndex, 1) return Object.assign({}, state, { products: deletedProductsState, isFetching: false, }) case 'INIT': return Object.assign({}, state, { products: [], isFetching: true, }) case 'INIT_SUCCEEDED': return Object.assign({}, state, { products: action.data, isFetching: false, }) default: return state } }ここまでで導入は完了です。
後は例としてinit()を呼んでやります。crud-front/src/Components/MainContainer.jsclass MainContainer extends React.Component { // -- いろいろ省略 -- componentDidMount() { this.props.init() } render() { if (this.props.isFetching === true) { return (<div />) } else { return ( <div className='app-main'> <FormContainer createProduct={this.props.create} /> <ProductsContainer deleteProduct={this.props.delete} updateProduct={this.props.update} /> </div> ); } } } // -- いろいろ省略 --
- 投稿日:2019-07-04T11:59:15+09:00
【React】最小限のreact-router実装で仕組みを理解する
概要
react-routerの使い方を理解するために最小限の実装をする
成果物
手順
- Reactの新規プロジェクト作成
- react-routerをインストール
- srcディレクトリの中身を削除
- index.jsとApp.jsを作成
1. Reactの新規プロジェクト作成
$ npx create-react-app hogehoge2. react-routerをインストール
$ cd hogehoge $ npm install react-router3. srcディレクトリの中身を削除
$ rm -rf ./src/*4. index.jsとApp.jsを作成
index.jsimport React from 'react'; import ReactDOM from 'react-dom'; import AppRouter from './App.js'; ReactDOM.render( <AppRouter />, document.getElementById('root') );App.jsimport React from 'react'; import { BrowserRouter as Router, Route, Link } from 'react-router-dom'; function Index(){ return <h2>Home page is here!</h2> } function About(){ return <h2>This is About page.</h2> } function Users(){ return <h2>This is User page.</h2> } function AppRouter(){ return( <Router> <div> <nav> <ul> <li> <Link to='/'>Home</Link> </li> <li> <Link to='/about/'>About</Link> </li> <li> <Link to='/users/'>Users</Link> </li> </ul> </nav> <Route path='/' exact component={Index} /> <Route path='/about/' component={About} /> <Route path='/users/' component={Users} /> </div> </Router> ); } export default AppRouter;Happy Hacking
!
参考
https://reacttraining.com/react-router/web/guides/quick-start
- 投稿日:2019-07-04T02:16:13+09:00
React HooksのみでWebアプリを作りました! ( 参考にどうぞ ! )
この記事について
私が作ったWebアプリをオープンソース化したので、そのWebアプリがどのような形で作られているかを解説した記事です。
オープンソース化した理由としては、これから
React Hooksを導入する人やWebアプリを作ってみたい人などの参考になればと思いオープンソース化しました。作ったもの
画像投稿ができるWebアプリです。
デモページ
ソースコード
製作期間
- 三か月ほど
実装した機能
- 認証機能
- 画像投稿機能
- いいね機能
- フォロー機能
- 通知機能
使用したものなど
- React
- React-Router
- dayjs
- TypeScript
- Firebase Authentication
- Firebase FireStore
- Firebase Storage
- Firebase Hosting
なぜ作ったのか?
前回の記事でJavaScriptで使えるフレームワークを作成したのですが、記事を投稿した後、中々上手い実装などが浮かばず、行き詰ってしまいました。
この状況から何とか抜け出そうと考えた結果、Webアプリを作ってみようと思い、出来たのが今回のWebアプリです。解説
ここからは、Webアプリがどのように出来ているかを解説したいと思います。
フォルダー構成について
フォルダー構成についてあまり深い関心が無い方もいるかもしれませんが、フォルダー構成はとても大切です。
個人で開発する場合も、チームで開発する場合も、フォルダー構成がしっかりしてないと良い開発はできないと私は思っています。
しかし、世の中の記事を見ていてもフォルダー構成に関する記事などがあまりありませんでした。
本当はあるのかもしれませんが、私には少ないように思えました。
なので、私が考えたフォルダー構成を皆さんに伝えたいと思います。│ ├─node_modules ├─public └─src ├─assets | └─icons ├─components │ ├─atoms │ ├─modules │ ├─molecules │ ├─organisms │ ├─pages │ └─templates ├─logics │ ├─actions │ ├─reducer │ └─util | └─uses └─testsフォルダーのみ表示してます
解説は、
srcフォルダー内のみにします。src/assets
画像などを保存するフォルダーです。
src/components
Reactコンポーネントを記述したファイルを入れるフォルダーです。
src/componentsのフォルダー構成は、Atomic Designに基づいて以下のようにコンポーネントのファイルを分割してます。components/atoms
一番小さいコンポーネントの単位です。
React Hooksを使って無いコンポーネントのみを入れるようにします。
atomsのコンポーネント例
atomsのコンポーネント例import React from "react"; import styles from "./LoadingBar.module.scss"; export const LoadingBar: React.FC<{ isLoading: boolean }> = ({ isLoading }) => { return isLoading ? <div className={styles.loading_bar} /> : null; };
components/molecules
atomsの次に小さいコンポーネントの単位です。
こちらも、atomsと同じくReact Hooksを使ってないコンポーネントのみを入れますが、
atomsとの違いは、要素数がatomsより多いコンポーネントになります。( 要素数がだいたい3~4個以上 )
moleculesのコンポーネント例
moleculesのコンポーネント例import React, { FormEvent } from "react"; import styles from "./SearchInput.module.scss"; interface SearchInputProps extends React.Props<{}> { maxWidth?: number; placeholder?: string; onSubmit: (inputValue: string) => void; } export const SearchInput: React.FC<SearchInputProps> = ({ onSubmit, maxWidth, placeholder = "キーワードを検索" }) => { const submit = (e: FormEvent<HTMLFormElement>) => { e.preventDefault(); onSubmit((e.currentTarget.search as HTMLInputElement).value); }; return ( <form className={styles.search__input} style={{ maxWidth }} onSubmit={submit} > <div className={styles.search_icon} /> <input type="text" name="search" autoComplete="off" placeholder={placeholder} /> </form> ); };
components/organisms
コンポーネントの大きさ関係なく
React Hooksを使っていれば問答無用でこちらのフォルダーに入れられます。
organismsのコンポーネント例
organismsのコンポーネント例import React from "react"; import { useUsers } from "../../../logics/util/uses/users"; import { useUtil } from "../../../logics/util/uses/util"; import { User } from "../../../types"; import { Button } from "../../atoms/Button"; interface FollowButtonProps extends React.Props<{}> { uid: User["id"]; className?: string; } export const FollowButton: React.FC<FollowButtonProps> = React.memo( ({ uid, className }) => { const { alerts } = useUtil(); const { isFollowee, current_user, followUser, unfollowUser } = useUsers(); const isFollowed = isFollowee(uid); const currentUserId = current_user ? current_user.id : ""; const onClick = () => { if (uid && currentUserId && currentUserId !== uid) { if (isFollowed) { unfollowUser(uid); } else { followUser(uid); } } else if (currentUserId) { alerts.follow_user.warn(); } else { alerts.required_login.warn(); } }; return ( <Button disabled={!uid} className={className} color={isFollowed ? "red" : "blue"} size="small" onClick={onClick} > {isFollowed ? "フォローを解除" : "フォローする"} </Button> ); } );
components/pages
一つのページを構成するコンポーネントを入れるフォルダーです。
pagesのコンポーネント例
pagesのコンポーネント例import React from "react"; import { RouteComponentProps } from "react-router"; import { Board } from "../../atoms/Board"; import { BoardItem, NotifyBoardItem } from "../../atoms/BoardItem"; import { DashboardTemplate } from "../../templates/DashboardTemplate"; import styles from "./Dashboard.module.scss"; import { useDashboard } from "./use"; export const Dashboard: React.FC<RouteComponentProps> = () => { const { notifies, finished, getNotifies } = useDashboard(); return ( <DashboardTemplate> <main className={styles.dashboard}> <Board headerLabel="通知一覧" minWidth={500}> {notifies.map(notify => ( <NotifyBoardItem key={`notify-item-${notify.id}`} notify={notify} /> ))} {finished ? null : ( <BoardItem className={styles.link} label="さらに10件の通知を表示" onClick={getNotifies} /> )} </Board> </main> </DashboardTemplate> ); };
components/templates
pagesでの共通部分を抜き出して、再利用できるようにしたコンポーネントを入れるフォルダーです。
templatesのコンポーネント例
templatesのコンポーネント例import React, { useEffect } from "react"; import useReactRouter from "use-react-router"; import { GlobalHeader } from "../../organisms/GlobalHeader"; import styles from "./PageTemplate.module.scss"; interface PageTemplateProps extends React.Props<{}> { title?: string; background?: string; className?: string; } export const PageTemplate: React.FC<PageTemplateProps> = ({ children, className, background }) => { const { location } = useReactRouter(); useEffect(() => { window.scrollTo(0, 0); }, [location.pathname]); return ( <div className={styles.page} style={{ background }}> <GlobalHeader /> <div className={`${styles.body} ${className || ""}`}>{children}</div> <div className={styles.footer}> <p>©︎ 2019 uttk</p> </div> </div> ); };
components/modules
上記のコンポーネントの分割に当てはまらないコンポーネントを入れるフォルダーです。
modulesのコンポーネント例
modulesのコンポーネント例import React, { useEffect, useState } from "react"; import { IconButton } from "../../atoms/Button"; import styles from "./Alerts.module.scss"; /** * 長いので省略 */ interface AlertProps extends React.Props<{}> {} export const Alert: React.FC<AlertProps> = React.memo(() => { const [alerts, setAlert] = useState<AlertElement[]>([]); const len = alerts.length; AlertsMg.elements = alerts; AlertsMg.dispatch = (elements: AlertElement[]) => { setAlert(elements); }; return ( <div> {alerts.map((alert, i) => ( <AlertBar key={alert.id} top={(len - i) * 64} alert={alert} displayTime={3000} /> ))} </div> ); }); interface AlertBarProps extends React.Props<{}> { top: number; alert: AlertElement; displayTime: number; } export const AlertBar: React.FC<AlertBarProps> = React.memo( ({ top, alert, displayTime }) => { const [style, setStyle] = useState(alert.style); useEffect(() => { let timeoutId = setTimeout(() => { setStyle(`${style} ${styles.alert_show}`); timeoutId = setTimeout(() => { setStyle(alert.style); timeoutId = setTimeout(alert.onClose, 300); }, displayTime); }, 0); return () => clearTimeout(timeoutId); }, []); return ( <li className={style} style={{ top }}> <p>{alert.message}</p> <IconButton size={16} icon="close_white" color="transparent" className={styles.close_btn} onClick={alert.onClose} /> </li> ); } );
src/logics
ロジック部分の処理を書いたファイルを格納するフォルダーです。
logics/actions
通信処理などをする関数を定義したファイルを入れるフォルダーです。
logics/reducer
後述するuseClutchで使う
reducerを定義したファイルを入れるフォルダーです。logics/util
actionsやreducerには当てはまらないものをここに入れます。logics/util/uses
このフォルダーには、コンポーネントで使う
React HooksのカスタムHooksを定義したファイルを入れます。コンポーネントについて
コンポーネントは、以下のフォルダー構成になります。
コンポーネントのフォルダー構成/ComponentName ├─ index.tsx <- JSXを書くファイル ├─ ComponentName.moduel.scss <- index.tsxのスタイルを書くファイル (フォルダーと同じ名前にする) └─ use.ts <- index.tsxで使うコールバックや変数を定義するファイル
index.tsxに描画部分を書き、ロジックなどはuse.tsに書くことによって、
描画部分はより描画に専念して書けますし、ロジックも同じように書けます。
修正する時も、色々とやりやすかったりします。
今回のWebアプリではpagesのみこのようにしていますが、後々は全部のコンポーネントをこの構成したいです。
ComponentName.module.scssは、index.tsxでindex.tsximport styles from "./ComponentName.module.scss"のようにするためです。
State管理について
このWebアプリでは、
Reactは使っていますが、Reduxは使って無いので工夫する必要があります。useClutch
このWebアプリでは、useClutchという
カスタムHooksを作成しました。
以下が、データフローになります。useClutchがやっていることは単純で、Promiseを返す
reducerを実行して、awaitして結果を受け取ると、
内部でsetStateをして描画を更新します。
こうすることで、reducer内で非同期処理が出来て処理を一か所にまとめることができます。clutchの使用例type Action = { type: "increment" } | { type: "decrement" }; interface StoreType { counter: number; } const state : StoreType = { counter: 0 }; const sleep = (t:number) => new Promise(r => setTimeout(r,t,t)); const reducer = async (state:number, action:Action) : Promise<StoreType> => { switch(action.type){ case "increment": await sleep(5000); return state + 1; case "decrement": await sleep(5000); return state - 1; default: return state; } } const App : React.FC = () => { const clutch = useClutch(reducer, store); const increment = () => clutch.dispatch("increment", { type: "increment" }).catch(console.error); const decrement = () => clutch.dispatch("decrement", { type: "decrement" }).catch(console.error); // 複数のactionを繋げることもできます。 // 描画更新は、すべてのactionが終了したときに発生します。 const add = () => clutch.pipe( "test", state => ({ type: "increment" }), state => state.counter > 10 ? null : ({ type: "increment" }) // 前のactionの結果を受け取って10以下ならさらにincrementする ).catch(console.error) return ( <div> <p>カウント : {clutch.counter}<p> <button onClick={increment}>カウントアップ</button> <button onClick={decrement}>カウントアップ</button> <button onClick={add}>1足して10以下ならさらに1足す</button> </div> ); }デザインについて
デザインは勉強の意味も込めて自分でやりましたが、ダサいので参考にはならないと思います。
しかしあれですね、デザイナーさんは凄いですね!
私もデザインについてもっと勉強しないといけないと感じました。( 特にAdobe。。。 )React HooksとTypeScriptは相性がいい!
React HooksとTypeScriptを使って開発しましたが、これが予想以上に良かったです!
React Hooksで、コンポーネントの記述量を減らして、その減らした分をTypeScriptに使うことで、
従来の記述量と同じくらいか少ないのに、型安全で、より設計しやすいコーディングをすることができました。
これから開発する際には、TypeScriptを使ったほうがよさそうですね。また、
React Hooksは柔軟に組み合わせることができるので、複雑な処理も関数に閉じ込めることができます。
上記で紹介したuseClutchもカスタムHooksで実装してます。
useClutchのソースコード
useClutch.tsimport { useRef, useState } from "react"; type Reducer<S, A> = (preState: S, action: A) => Promise<S>; type GetStoreType<R extends Reducer<any, any>> = R extends Reducer<infer S, any> ? S : any; type GetActionType<R extends Reducer<any, any>> = R extends Reducer< any, infer A > ? A : any; type ActionCreator<S, A> = (preState: S) => A | null; type RequestStatus = "start" | "success" | "cancel" | "error"; type ListenRequestCallback = (request: string, status: RequestStatus) => void; type CancelCallback = () => void; export interface Clutch<StoreType, ActionType> { state: StoreType; request: <T>( req: string, promiseCreator: () => Promise<T> ) => Promise<T | null>; cancelRequest: (request: string) => boolean; listenRequest: (cb: ListenRequestCallback) => () => void; isLoading: (request?: string) => boolean; pipe: ( request: string, ...funcs: Array<ActionCreator<StoreType, ActionType>> ) => Promise<void>; dispatch: (request: string, action: ActionType) => Promise<void>; } export const useClutch = <R extends Reducer<any, any>>( asyncReducer: R, initializeState: GetStoreType<R> ): Clutch<GetStoreType<R>, GetActionType<R>> => { type StoreType = GetStoreType<R>; type ActionType = GetActionType<R>; // 描画を更新するためのState const [pureState, setState] = useState<StoreType>(initializeState); const { progressCancel, progressPromise, listenCallbacks, notifyRequest, resolveAsync, updateState, clutch } = useRef({ // 実行中のPromise処理をキャンセルするコールバックを保持するMap progressCancel: new Map<string, CancelCallback>(), // 実行中のPromiseインスタンスを保持するMap progressPromise: new Map<string, Promise<any>>(), // 非同期処理状況をlistenする関数を保持するSet listenCallbacks: new Set<ListenRequestCallback>(), // 非同期処理の状態をlistenCallbackに伝える関数 notifyRequest: (request: string, status: RequestStatus) => { listenCallbacks.forEach(cb => cb(request, status)); }, // 引数に渡されたPromiseを実行しawaitして結果を返す resolveAsync: <T>( request: string, cb: () => Promise<T> ): Promise<T | null> => { const promise = progressPromise.get(request); if (promise) { return promise as Promise<T | null>; } notifyRequest(request, "start"); const cancelCallback = new Promise<"cancel">(re => { progressCancel.set(request, () => re("cancel")); }); const progress = Promise.race([cb(), cancelCallback]) .then(result => { notifyRequest(request, result === "cancel" ? "cancel" : "success"); progressCancel.delete(request); progressPromise.delete(request); return result === "cancel" ? null : result; }) .catch(e => { notifyRequest(request, "error"); progressCancel.delete(request); progressPromise.delete(request); return Promise.reject(e); }); progressPromise.set(request, progress); return progress; }, // stateを更新する関数 updateState: async ( request: string, oldState: StoreType, promiseCreator: (oldState: StoreType) => Promise<StoreType> ) => { const newState = await resolveAsync(request, () => promiseCreator(oldState) ); const updated: Partial<StoreType> = {}; if (newState) { for (const key in oldState) { if (oldState[key] !== newState[key]) { updated[key] = newState[key]; } } if (Object.keys(updated).length > 0) { clutch.state = { ...clutch.state, ...updated }; setState(clutch.state); } } }, // Propsに渡されるclutchオブジェクト clutch: { state: pureState, // 処理が実行中かのフラグを返す関数 isLoading: (request?: string): boolean => { if (request) { return progressPromise.has(request); } return !!progressPromise.size; }, // reducerを通さないPromiseを監視するようにする request: <T>( req: string, promiseCreator: () => Promise<T> ): Promise<T | null> => { return resolveAsync<T>(req, promiseCreator); }, // 実行中のPromiseを中断する cancelRequest: (request: string): boolean => { const cancel = progressCancel.get(request); if (cancel) { cancel(); return true; } else { return false; } }, // listenCallbackを設定する関数 listenRequest: (cb: ListenRequestCallback): (() => void) => { listenCallbacks.add(cb); return () => listenCallbacks.delete(cb); }, // 引数に渡されたActionCreatorの順番で、reducerを実行する pipe: async ( request: string, ...funcs: Array<ActionCreator<StoreType, ActionType>> ): Promise<void> => { const promiseCreator = async (oldState: StoreType) => { for (const fn of funcs) { const action = fn(oldState); if (action) { oldState = await asyncReducer(oldState, action); } } return oldState; }; await updateState(request, { ...clutch.state }, promiseCreator); }, // action&payloadをreducerに渡して実行する dispatch: async (request: string, action: ActionType): Promise<void> => { const promiseCreator = (oldState: StoreType) => { return asyncReducer(oldState, action); }; await updateState(request, { ...clutch.state }, promiseCreator); } } }).current; return clutch; };
使った技術の感想
React & React Hooks
Reactは以前から使っていたので色々とやり易かったですが、React Hooksによってより使いやすくなりました。
例えば、上で説明したpagesのコンポーネントのindex.tsxの中身は、以下のようになりました。pages/Home/index.tsximport React from "react"; import { RouteComponentProps } from "react-router"; import { Button } from "../../atoms/Button"; import { Icon } from "../../atoms/Icon"; import { PageTemplate } from "../../templates/PageTemplate"; import styles from "./Home.module.scss"; import { useHome } from "./use"; export const Home: React.FC<RouteComponentProps> = () => { const { isLogin, isCheked, login, goToTrend } = useHome(); return ( <PageTemplate className={styles.page}> <div className={styles.home}> <div className={styles.title}> <div className={styles.wrapper}> <h1 className={styles.title_label}>NIJINOWA</h1> <div className={styles.wrapper}> <p> <big>NIJINOWA</big> は二次創作を投稿できるサービスです </p> </div> <div className={styles.container}> <div className={styles.wrapper}> <Button color="blue" onClick={goToTrend}> トレンドを見る </Button> </div> <div className={styles.wrapper}> <Button disabled={!isCheked} onClick={login}> {isLogin ? "ダッシュボードに移動" : "Googleアカウントでログイン"} </Button> </div> <div className={styles.wrapper}> <a href="https://twitter.com/uttk8128" target="_blank" className={styles.twitter_btn} > <Icon icon="twitter_white" size={32} /> 製作者のTwitter </a> </div> </div> </div> </div> </div> </PageTemplate> ); };見てわかるように、ロジックの処理が書かれていないことが分かります。
これは、useHome()というカスタムHooksにロジック部分を書いているためです。
以下がuseHome()の実際のソースコードになりますpages/Home/use.tsimport { useEffect, useState } from "react"; import useReactRouter from "use-react-router"; import { useAuth } from "../../../logics/util/uses/auth"; export const useHome = () => { const { history } = useReactRouter(); const { isLogin, isCheked, loginCheck, loginWithGoogle } = useAuth(); const [isPush, setPush] = useState(false); const login = () => { if (isLogin) { history.push("/dashboard"); } else { setPush(true); loginWithGoogle(); } }; useEffect(() => { if (!isCheked) { loginCheck(); } else if (isLogin && isPush) { history.push("/dashboard"); } }, [history, isCheked, isPush, isLogin]); return { isLogin, isCheked, login, goToTrend: () => history.push("/trends") }; };こうやって、
ViewとLogicを分けて書けるようになったのも、React Hooksによるところが大きいかと思います。
また、ファイルを分けているので関心の分離といった観点からもいいのではないでしょうか。TypeScript
TypeScriptを使うことによって、コンポーネントやカスタムHooksに型情報を追加し、より扱い易いものを作ることができます。
また、上記でも言ったように、本当にReact HooksとTypeScriptの相性は良く、
TypeScriptの記述量の多さに嫌気が差す方もいるかとは思いますが、React Hooksがコンポーネントの記述量を減らしてくれているので、
実質今までの記述量とあまり変わらないです。むしろ少なくなっている方です。
さらに、同じ記述量でもTypeScriptの方が型安全ですし、VSCodeなどのTypeScriptをサポートしている
エディタを使えば入力補完も効いて、素晴らしい開発者体験が得られます。
React Hooksを導入する・しているなら、TypeScriptも導入することをお勧めします。Firebase
Firebaseは、小さいサービスなどを作るのに本当に便利です。
今回のWebアプリでは、Auth,FireStore,Storage,Hostingを使いました。
今回の開発では、特にFireStoreのデータ構造とreducerによる処理の分割が、相性がとてもよかったです。例えば、
userドキュメントにfollowerサブコレクションがあったとき、followerのreducerを作れば、
それだけで、followerの取得や変更などがアプリに追加できます。
これは、後からデータを追加する際にも同じような感じでアプリを拡張できます。つまり何が言いたいのかというと、
FireStoreのコレクションごとにreducerを作ることにより、
コレクションを新しく追加したとき、新しくreducerを作って、Webアプリに機能を追加し、
編集の際も、そのreducerのみを変更すればいいのです。また、ログイン機能を簡単に入れれたり、サイトを簡単にデプロイ出来たりと
Firebaseの凄さに恐縮してしまいます。作ってみた感想
実は今回Webアプリ製作はフレームワークの行き詰まりの解消のためでもあったので、
普通に作るのではなく、二つの制限をかけて作りました。制限
1. React.jsで作るが、Classコンポーネントは使わない
2. なるべく、他のnpmモジュールに頼らず自分で作る上記の制限の中で作ることにより、より設計やデータの流れについて考えるようになり色々とフレームワークのアイディアが見えてきたので、Webアプリを作ってよかったと思いました。
あと、やっぱりモノづくりは楽しい!
後書き
今回のWebアプリを作るきっかけになった、前回の記事で紹介したフレームワークですが、
前回の記事にいいね!などしてくれた方たちには大変申し訳ないですが、
このまま開発してもよくならないように感じたので、開発するのは終わりにしたいと思います。
しかし、また別のフレームワークを企画しています!最後まで、読んでいただきありがとうございます。
何か気になることなどがあれば、お気軽にコメントください!
あと、最近Twitter始めたのでそちらでも大丈夫です!
それではまた?
- 投稿日:2019-07-04T02:15:41+09:00
react-loopsでReactのリスト表示を美しく簡潔に書く
2019年も半分を過ぎました。早い、、
今回はReactのリスト表示の話で、react-loopsという新しいライブラリを見つけて使ってみたのでゆるく紹介します。https://github.com/leebyron/react-loops
作者は元Facebookのエンジニアで、ReactやGraphQL周りのOSSコントリビューターであるLee Byronです。
普通のやり方
JSXのメリットの一つは全てがJavaScriptである点です。リスト表示をする場合は下記のようにArray.mapを使います。
function List() { const itemList = ['a', 'b', 'c'] return ( <ul> {itemList.map(item => ( <li key={item}>{item}</li> ))} </ul> ) }react-loopsを使うと
react-loopsを使うと、少しテンプレートっぽい書き方になります。ちょっとVueやAngularっぽいと思う人もいるかもしれません。keyが要らないのにも注目してください。
import { For } from 'react-loops' function List() { const itemList = ['a', 'b', 'c'] return ( <ul> <For of={itemList}>{item => <li>{item}</li>}</For> </ul> ) }配列が空の場合を考慮したリスト
配列が空の場合は、"no items" と出したいとしましょう。
普通のJSX
<ul> {itemList.length < 1 ? ( <div>no items</div> ) : ( itemList.map(item => <li>{item}</li>) )} </ul>あれあれ、ちょっと汚くなってきました。
react-loops
<ul> <For of={itemList} ifEmpty={<div>no items</div>}> {item => <li>{item}</li>} </For> </ul>美しくなりました!
ifEmptyにstringやcomponentを指定すると、配列が空のときはループを回す代わりに表示してくれます。何が良い?
1. 非Reactな人にわかりやすい
日本国内においては最近はVueを勉強してからReactに来る人が多かったり、Railsなどでテンプレートエンジンに慣れ親しんだ人がReactを触る機会が多いと思います。そういった場合にほとんどのテンプレートエンジンがサポートしているループ表示にちかいインターフェースはわかりやすいです。
2. なんだかんだこういうの欲しかった
Reactエンジニアとしてはどうでしょう。自分はかなりReact的な思想に共感してVueでもAngularでもなくReactを楽しみ、好んで書いています。素のJSでもできるのにわざわざ入れる必要のない「おせっかいなライブラリ」はSimpleが強みのReactらしくない部分があり嫌いでした。
ただ、react-loopsはやっていることは単純だけど便利で個人的にはRight Level of Abstractionな気がします。3. 痒いところに手が届くAPI
Loop iteration metadataがなかなかに素晴らしいです。
- index
- length
- key
- isFirst
- isLast
実際のアプリケーション開発をしていると、「配列の最後だけ、最初だけXXしたい」とか、ふと配列のlengthが欲しくなったりするものですが、そういったときにreact-loopsのLoop iteration metadataは痒いところに手が届くAPIとして優秀です。
<ul> <For of={itemList} ifEmpty={<div>no items</div>}> {(item, {isLast}) => ( <li>{item} {isLast && '最後'}</li> )} </For> </ul>こんな感じ。なんだかんだで素のJSだとどう書いてもごちゃっとなりがちです。「アプリケーションが複雑なのが悪い」と文句を言って跳ね返すのは簡単ですが、UX向上を考えるとこの手の対応は必要ですし、そうした時にコードが汚くなる後ろめたさがないのはDXも良いと感じます。
Reactぽくなくない?
同じFAQがgithubのREADMEにありますが、自分も最初見たときは「Reactぽくないからだめじゃん」と思ってました。ただ、少し使ってから考えてみるとAPIはシンプルでわかりやすいし、ただ単純にmapで配列を回す部分にそこまで素のJSであるメリットはないのではという考えに至りました。
雑感、あとがき
Reactエコシステムは日々新しい話題やアイディアが出てくるOSSのるつぼです。OSSの1ユーザーであるエンジニアにとっては取捨選択が難しいですが、こういったシンプルでエレガントなOSSを見つけるのは宝を発見したような感覚で楽しいと感じます。
今まで他に自分がシンプルかつエレガントと思ったのはstyled-components, redux-thunk, immer, react-testing-libraryあたりですが、今回新たにreact-loopsが加わりました。
- 投稿日:2019-07-04T01:37:48+09:00
Reactコンポーネントのライフサイクル
React で使われているコンポーネントは、プロパティやステートの変化を受けて状態を変化します。最終的にぺージから破棄されるまでの間をコンポーネントのライフサイクルと言われます。
Reactでは、ライフサイクルの辺ごと、様々なメソッドが呼びだれています。
これにより、コンポーネントの表示や破棄の各タイミングで、独自の振る舞いを実装することができます。以下がライフサイクル時に呼ばれるメソッド群です。
componentWillMount : コンポーネントの描画の直前に呼びばれる。
componentDidMount :コンポーネントの描画の直後に呼ばれる。
componentWillUnmount :コンポーネントの解放のタイミングで呼ばれる。
componentWillReceiveProps:プロパティを受け取る直前に呼ばれる。
shouldComponentUpdate コンポーネントの更新の可否を決定する
componentWillUpdate コンポーネントの更新の直前に呼ばれる。
componentDidUpdate コンポーネントの更新の直後に呼ばれる。このうちは、componentWillMount :は、コンストラクタで代用可能であり、非推奨とされているらしいです。
コンポーネントのライフサイクルは大きく2つに分類され例外の処理をハンドリングすることもできます。実行例は追々。
コンポーネントのMount(生成および破棄)
コンポーネントのUpdate(更新)コンポーネントのErrorHandling (例外発生)





