- 投稿日:2019-10-21T23:50:57+09:00
Webpackerを使わずにRails,React,Typescriptアプリのベースを作る
はじめに
仕事でReactを使うようになったので勉強がてら自分でReact,Typescript,Railsを使ったアプリを勉強していましたが
意外と最初の設定でつまずいたのでまとめます。
Rails new
bundle init #Gemfileにてrailsのコメントアウトを外す bundle install --path vendor/bundle bundle exec rails new . bundle install bundle updateWebpackerを除く
Gemfileからwebpackerを除きます。
React表示用のviewを作成
bundle exec rails g controller react-ui::home indextsconfigを作成
typescriptを使うためにはtsconfigを作成しなくてはいけない
railsのルートディレクトリにtsconfig.jsonファイルを作成して中身を以下の様にする
{ "compilerOptions": { "strictNullChecks": true, "noUnusedLocals": true, "noImplicitThis": true, "alwaysStrict": true, "outDir": "./dist/", "sourceMap": true, "noImplicitAny": false, "lib": ["dom", "ES2017"], "module": "commonjs", "target": "es5", "jsx": "react", "baseUrl": ".", "paths": { "import-png": ["types/import-jpg"] }, "typeRoots": ["types", "node_modules/@types"] } }npm install webpack -D
webpackを使える様にする
npm install webpack -D npm install webpack-cli -D npm isntall webpack-dev-server -Dwebpack記載
railsのルートディレクトリにwebpack.config.jsファイルを作成する。
webpack.config.jsを以下の様に記載する
npm install typescript npm install html-webpack-plugin -D npm install webpack-manifest-plugin -D npm install ts-loader style-loader css-loader file-loader url-loader -D不要なエラーが発生するので vendor/bundle/ruby/2.6.0/gems 以下のwebpackerのフォルダを削除します。
const HtmlWebpackPlugin = require("html-webpack-plugin") const ManifestPlugin = require("webpack-manifest-plugin") const path = require('path'); module.exports = { mode: "development", entry: { home: `${__dirname}/app/webpack/entry/home` }, output: { path: `${__dirname}/public/packs`, publicPath: `${__dirname}/app/webpack`, filename: "[name].js" }, module: { rules: [{ test: /\.(tsx|ts)$/, loader: "ts-loader" }, { test: /\.css/, use: [ "style-loader", { loader: "css-loader", options: { url: true } } ] }, { test: /\.(jpg|png)$/, loader: "file-loader?name=/public/[name].[ext]" }, { test: /\.(eot|svg|woff|ttf|gif)$/, loader: "url-loader" } ] }, watchOptions: { poll: 500 }, resolve: { extensions: [".ts", ".tsx", ".js"] }, plugins: [ new HtmlWebpackPlugin({ template: `${__dirname}/app/webpack/index.html`, filename: "index.html" }), new ManifestPlugin({ fileName: "manifest.json", publicPath: "/packs/", writeToFileEmit: true }) ], devServer: { publicPath: "/packs/", historyApiFallback: true, inline: true, hot: true, port: 3035, contentBase: "/packs/" } };app/webpack以下にindex.htmlを作成して以下の様に記載します。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>Document</title> </head> <body> </body> </html>package.jsonにscriptを追加
package.jsonが作成されているはずなのでそのファイルを開きscriptを以下の様に設定する
"scripts": { "start": "webpack-dev-server --hot --inline --contentbase public/packs/ --mode development" },これで npm start をコマンドを打ったときにwebpack-dev-serverが起動してくれる
reactを使えるようにする
npm install react -D npm install react-dom -Dwebpack.config.jsのentryからjavascriptを読み込むので
${__dirname}/app/webpack/entry/homeのフォルダにindex.tsxを作成します。
そして中身を以下の様にします。
import * as React from "react"; import { render } from "react-dom"; render(<div>Home</div>, document.getElementById("root"));Rails側で読み込むロジック追加
webpackerを使わないのでjavascript_pack_tagは使えません。
ですので
react_ui/home/index.html.erbを以下の様にします。
<div id="root"></div> <% if Rails.env == "development" %> <script src="http://localhost:3035/packs/home.js"></script> <% else %> <script src="/packs/home.js"></script> <% end %>config/routes.rbに
root to: "react_ui/home#index"を追加
layouts/application.html.erbの
<%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>を削除します。
動作確認
bundle exec rails s npm startを実行してlocalhost:3000にアクセスすれはHomeが表示されるはずです。
あとは好きにreactを書いていけばOKです。
- 投稿日:2019-10-21T20:24:20+09:00
React チュートリアルの三目並べに Redux を導入する
はじめに
React に入門する際に、三目並べを作成する公式チュートリアルに取り組む方は多いと思います。
実際に運用されている React プロダクトのほとんどは Redux と何らかのミドルウェアを併用していますが、このチュートリアルでは React 単体についてしか学べません。
当然と言えば当然なのですが、折角 React に入門したのですから、そのままの流れで Redux (と react-redux) も導入したいと考えるのが人情というものです。
という訳で、本記事ではその三目並べに Redux を導入してみます。
Redux 公式チュートリアルと併せて読んでみてください。また、続編として redux-observable とかを導入する記事も書いたので、興味があればそちらもどうぞ。
事前準備
まず、React 公式チュートリアルのタイムトラベルの実装まで済ませましょう。
三目並べが完成すると、Game
component がいくつかの状態を持つと思います。
この状態を Redux に管理してもらいましょう。Redux 導入をやりやすくするために、まずはファイルを分割します。
ここを参考に、以下のファイルに JavaScript コードを分割してください。
- src/components.jsx
- src/index.jsx
Redux / react-redux 導入
Redux とは
Redux is a predictable state container for JavaScript apps.
It helps you write applications that behave consistently, run in different environments (client, server, and native), and are easy to test. On top of that, it provides a great developer experience, such as live code editing combined with a time traveling debugger.
You can use Redux together with React, or with any other view library. It is tiny (2kB, including dependencies), but has a large ecosystem of addons available.
Getting Started with Redux・Redux より引用
react-redux とは
React Redux is the official React binding for Redux. It lets your React components read data from a Redux store, and dispatch actions to the store to update data.
redux / react-redux をインストール
以下のコマンドで redux, react-redux をプロジェクトに追加します。
yarn
を使用する場合は適宜読み替えてください。npm install redux react-redux
action を追加
Actions are payloads of information that send data from your application to your store. They are the only source of information for the store. You send them to the store using store.dispatch().
Actions・Redux より引用
(store
の説明は後で出てくるので、今はまだわからなくても大丈夫です。)三目並べで発生する action は以下の 2 つとします。
- どこかのマスがクリックされる
- いずれかの履歴がクリックされる
前者を表現した
action
を以下に示します。src/actions.js/* * action types */ export const CLICK_SQUARE = "CLICK_SQUARE"; /* * action creators */ export function clickSquare(index) { return { type: CLICK_SQUARE, index }; }「マスがクリックされた」という情報を表す
CLICK_SQUARE
action type と、「ある場所のマスがクリックされた」という action を生成する action creator が定義できました。それでは、ここに「いずれかの履歴がクリックされた」を表す
action type
とaction creator
を追加しましょう。
実装例はここにあります。reducer を追加
Reducers specify how the application's state changes in response to actions sent to the store. Remember that actions only describe what happened, but don't describe how the application's state changes.
Reducers・Redux より引用
reducer
の実装を以下に示します。src/reducers.jsimport { combineReducers } from "redux"; import { CLICK_SQUARE, JUMP_TO_PAST } from "./actions"; const initialState = { history: [ { squares: Array(9).fill(null) } ], stepNumber: 0, xIsNext: true }; function game(state = initialState, action) { switch (action.type) { case CLICK_SQUARE: const history = state.history.slice(0, state.stepNumber + 1); const current = history[history.length - 1]; const squares = current.squares.slice(); if (calculateWinner(squares) || squares[action.index]) { return state; } squares[action.index] = state.xIsNext ? "X" : "O"; return { history: history.concat([ { squares: squares } ]), stepNumber: history.length, xIsNext: !state.xIsNext }; default: return state; } } export const app = combineReducers({ game }); // ======================================== function calculateWinner(squares) { const lines = [ [0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6] ]; for (let i = 0; i < lines.length; i++) { const [a, b, c] = lines[i]; if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) { return squares[a]; } } return null; }
initialState
は Redux のstate
の初期値です。
ちなみにこれはGame
component の state をそのまま持ってきただけです。
game()
がreducer
です。
こちらもGame
component のhandleClick()
とほとんど同じです。
combineReducers()
は複数のreducer
を 1 つにまとめるための関数です。
(今回の例では大した役割を担っていないので、あまり気にしなくて良いです。)それでは、ここに「履歴がクリックされた」という action に対応する処理を追加しましょう。
実装例はここにあります。store を追加
In the previous sections, we defined the actions that represent the facts about “what happened” and the reducers that update the state according to those actions.
The Store is the object that brings them together.
Store・Redux より引用
(ここはとても重要なところなので、Redux をよく知らない人は引用元をよく読むことをお勧めします。)
createStore()
関数にreducer
を渡すことでstore
を作ることができます。src/index.jsximport React from "react"; import ReactDOM from "react-dom"; import { createStore } from "redux"; import { Game } from "./components"; import { app } from "./reducers"; import "./index.css"; const store = createStore(app); ReactDOM.render(<Game />, document.getElementById("root"));ここで作成した
store
は、container component
を追加した後に使用します。container component を追加
私がContainerと名付けたコンポーネントの特徴は以下の通りです。
- どのように機能するか、ということと結びついてる。
- PresentatinalコンポーネントとContainerコンポーネントの両方を内部に持つことができるが、たいていの場合は自分自身のためのDOMマークアップとスタイルを「持たない」。
- データと挙動を、Presentationalコンポーネントや、他のContainerコンポーネントに対して提供する。
- Fluxのアクションをcallしたり、アクションをコールバックとしてPresentatinalコンポーネンへ提供する。
- たいていの場合は状態を持ち、データの源としての役割を担う。
- higher order componentを用いることで生成される。例えばReact Reduxのconnect()やRelayのcreateContainer()やFlux UtilsのContainerCreate()である。
日本語訳: Presentational and Container Components より引用
(Usage with React・Redux にも記述があるので、そちらも参照してみてください。)React / Redux を使う時の
container component
というと、だいたいこんな感じになります。src/containers.jsimport { connect } from "react-redux"; import { clickSquare, jumpToPast } from "./actions"; import { Game } from "./components"; const mapStateToProps = (state, ownProps) => { return state.game; }; const mapDispatchToProps = (dispatch, ownProps) => { return { handleClick: index => { dispatch(clickSquare(index)); }, jumpTo: () => {} }; }; export const GameContainer = connect( mapStateToProps, mapDispatchToProps )(Game);
mapStateToProps()
は、Redux のstate
を props として適当な形に整形する関数です。
mapDispatchToProps()
は、Redux のdispatcher
を props として適当な形に整形する関数です。
(action
をstore
に渡すことをdispatch
と呼び、それをする関数のことをdispatcher
と呼びます。)
connect()()
は、Redux のstate
と React component を接続する関数です。
mapStateToProps
ないしmapDispatchToProps
と React component を渡すと、container component が返ってきます。これで
Game
component はGameContainer
component から Redux の諸々を props 経由で受け取ることができるようになりました。
なので、それらを使うよう書き換えましょう。src/components.jsxexport class Game extends React.Component { render() { const history = this.props.history; const current = history[this.props.stepNumber]; const winner = calculateWinner(current.squares); const moves = history.map((step, move) => { const desc = move ? `Go to move #` + move : "Go to game start"; return ( <li key={move}> <button onClick={() => this.props.jumpTo(move)}>{desc}</button> </li> ); }); let status; if (winner) { status = "Winner: " + winner; } else { status = "Next player: " + (this.props.xIsNext ? "X" : "O"); } return ( <div className="game"> <div className="game-board"> <Board squares={current.squares} onClick={i => this.props.handleClick(i)} /> </div> <div className="game-info"> <div>{status}</div> <ol>{moves}</ol> </div> </div> ); } }これでこの component は内部に state を持たないし、外部の state (つまり Redux の
state
)の存在も知らないものになりました。
アプリケーションの状態と完全に切り離すことができたので、この component はテストをしやすいはずです。さて、先ほど提示した
mapDispatchToProps()
が返却しているjumpTo
プロパティには不足があります。
これを完成させましょう。
実装例はここにあります。container component と Redux の state を接続
それでは、今まで
Game
component を呼び出していた箇所をGameContainer
に書き換えましょう。src/index.jsximport React from "react"; import ReactDOM from "react-dom"; import { createStore } from "redux"; import { Game } from "./components"; import { app } from "./reducers"; import "./index.css"; const store = createStore(app); ReactDOM.render(<GameContainer />, document.getElementById("root"));次に、
Provider
component を導入します。The option we recommend is to use a special React Redux component called <Provider> to magically make the store available to all container components in the application without passing it explicitly. You only need to use it once when you render the root component:
src/index.jsximport React from "react"; import { render } from "react-dom"; import { createStore } from "redux"; import { Provider } from "react-redux"; import { app } from "./reducers"; import { GameContainer } from "./containers"; import "./index.css"; const store = createStore(app); render( <Provider store={store}> <GameContainer /> </Provider>, document.getElementById("root") );
Provider
component は配下にいる container component にredux state
とdispatcher
を良い感じに渡してくれます。
これで三目並べに Redux を導入できたはずです。ここに、以下のような変更を加えても良いでしょう。
calculateWinner()
をsrc/utils.js
に切り出す- 全ての React component を functional component にする
Game
component のrender()
内で定義されているcurrent
およびstatus
をmapStateToProps()
内に移すBoard
component に渡されているonClick()
およびsquares
props を Redux のstate
から直接受け取るようにするこれで Redux の導入が完了しました。
ちなみに、導入の全容はこのリポジトリにあります。更なる発展
プロダクトとして使うには(場合によりますが)まだ不十分です。
例えば以下のようなものが必要でしょう。
- ミドルウェア
- redux-saga など
- 三目並べに redux-observable を導入する記事を
後日書きます書きました↓- TypeScript
- React + Redux なフロントを構築するのであれば、TypeScript は必須です
- テスト機構
- 拡張性のあるディレクトリ構造
- router ライブラリ
- @reach/router など (使ったことないけど)
- CSS ライブラリ
- 僕の最近のお気に入りは Spectre.css です
- エラー監視ツール
- Sentry など
- サイト分析ツール
- Google Analytics や Intercom など
- 投稿日:2019-10-21T18:47:34+09:00
VueとReact(Hooksも)をアニメーション実装から比較する
Vueで、アニメーションするコンポーネントを作ったので、
ついでにReactでも作ってみると実装方法が違ったので比較する作ったものは、ハンバーガーボタン(押したらバツになるやつ)
svgをGSAPのTweenMaxでclickイベントをトリガーにアニメーションさせることでハンバーガーボタンを作成するとりあえずsvgをただTweenMaxでアニメーション
DOMを取得してTweenMaxでアニメーションする例
ボタンを押せばアニメーションSee the Pen SvgTween by Saito Takashi (@7_asupara) on CodePen.
Vueで作成する
See the Pen VueHamburger by Saito Takashi (@7_asupara) on CodePen.
Vue<template> <div class="button" v-on:click="toggle"> <!-- クリックイベントを付与 --> <svg :viewbox="viewbox" :width="size" :height="size" style="overflow: visible"> <!-- svgの各属性に変数をバインディング --> <line x1="0" :y1="line1Y1" :x2="size" :y2="getTopLimit()" :stroke="stroke" :stroke-width="strokeWidth" /> <line :x1="line2X1" :y1="halfSize" :x2="size" :y2="halfSize" :stroke="stroke" :stroke-width="strokeWidth" /> <line x1="0" :y1="line3Y1" :x2="size" :y2="getBottomLimit()" :stroke="stroke" :stroke-width="strokeWidth" /> </svg> </div> </template> <script> export default { data() { // dataオブジェクト 変更を通知したい変数とかはここで定義 return { size: 50, stroke: 'black', strokeWidth: 6, speed: 0.4, line1Y1: 0, line2X1: 0, line3Y1: 0, menuCloseFlg: false }; }, computed: { // 加工した返り値の変数を作りたい場合はここで定義 viewbox: function () { return `0 0 ${this.size} ${this.size}` }, halfSize: function () { return this.size / 2 } }, mounted () { // mountedではcomputedが動かないのでmethodで初期化 this.line1Y1 = this.getTopLimit() this.line3Y1 = this.getBottomLimit() }, methods: { getTopLimit () { return this.strokeWidth / 2 }, getBottomLimit () { return this.size - (this.strokeWidth / 2) }, toggle () { // クリックイベント if (this.menuCloseFlg) { TweenMax.to( this.$data, this.speed, { line1Y1: this.getTopLimit(), line2X1: 0, line3Y1: this.getBottomLimit(), ease: Expo.easeIn } ) } else { TweenMax.to( this.$data, this.speed, { line1Y1: this.getBottomLimit(), line2X1: this.size, line3Y1: this.getTopLimit(), ease: Expo.easeIn } ) } this.menuCloseFlg = !this.menuCloseFlg } } } </script>Vueでは、dataオブジェクトを変更すると、自動で画面にも反映(rerender)してくれる
なので、svgの動かしたい属性にdataオブジェクトのプロパティを付与してその値を変更すれば勝手に画面に反映してくれるこの例の場合は、toggleメソッドでDOMではなく、svg属性に割り当てたdataオブジェクトの値を直接TweenMaxで変更してアニメーションさせている
この特性のおかげで値の変更が直感的にできるので、アニメーションを扱う上でとてもVueはいいと思うsvgを使用した動的なUIが簡単に作れそう
Reactで作成する
とりあえずClassComponentを使って作成する
See the Pen ReactHamburger by Saito Takashi (@7_asupara) on CodePen.
Reactimport React from 'react'; class App extends React.Component { constructor(){ super(); // 必要な変数の定義 this.size = 50; this.speed = 0.4; this.strokeWidth = 6; this.halfSize = this.size / 2; this.halfStrokeWidth = this.strokeWidth / 2; this.topLimit = this.halfStrokeWidth; this.bottomLimit = this.size - this.halfStrokeWidth; this.viewbox = `0 0 ${this.size} ${this.size}`; this.stroke = 'black' // 変更を通知したい変数はここでstateとして定義 this.state = { closeFlg: false }; // DOMノードを取得するための準備 this.line1Ref = null; this.line2Ref = null; this.line3Ref = null; } handleClick = () => { // svgの属性ではなく、DOMノードを直接Tweenさせる if (!this.state.closeFlg) { TweenMax.to(this.line1Ref, this.speed, { attr: { y1: this.bottomLimit }, ease: Expo.easeIn }) TweenMax.to(this.line2Ref, this.speed, { attr: { x1: this.size }, ease: Expo.easeIn}) TweenMax.to(this.line3Ref, this.speed, { attr: { y1: this.topLimit }, ease: Expo.easeIn }) } else { TweenMax.to(this.line1Ref, this.speed, { attr: { y1: this.topLimit }, ease: Expo.easeIn }) TweenMax.to(this.line2Ref, this.speed, { attr: { x1: 0 }, ease: Expo.easeIn }) TweenMax.to(this.line3Ref, this.speed, { attr: { y1: this.bottomLimit }, ease: Expo.easeIn }) } // Reactでは変更の通知はsetStateが必須 this.setState(prevState => ({ closeFlg: !prevState.closeFlg, })) } // svgのlineタグにrefを付与してDOMノードを取得する render() { return( <div className="button" onClick={this.handleClick}> <svg viewBox={this.viewbox} width={this.size} height={this.size} style={{ overflow: 'visible' }}> <line ref={ c => this.line1Ref = c} x1="0" y1={this.topLimit} x2={this.size} y2={this.topLimit} stroke={this.stroke} strokeWidth={this.strokeWidth} /> <line ref={ c => this.line2Ref = c} x1="0" y1={this.halfSize} x2={this.size} y2={this.halfSize} stroke={this.stroke} strokeWidth={this.strokeWidth} /> <line ref={ c => this.line3Ref = c} x1="0" y1={this.bottomLimit} x2={this.size} y2={this.bottomLimit} stroke={this.stroke} strokeWidth={this.strokeWidth} /> </svg> </div> ); } }Reactでは、変数(state)変更の通知をsetStateを使って行って初めて画面に反映(rerender)される
(Reactはstate変更の通知をするかどうかをプログラマーがコントロールしたいので、setStateを実行することを採用している)
なので、Vueのように値を変更するだけでは画面に反映されないTweenMaxのようなトゥイーン系のライブラリはフレーム毎の値の変更をループでよしなにやってくれるが、svgの属性値の変更をする場合、この中にsetStateをねじ込むことができないので変更の通知ができなくアニメーションされないはず
そこで、ReactでTweenしたい場合は、Refを使用してDOMノードを取得しDOMに対してTweenMaxでアニメーションする(要はjQueryとかと同じで昔ながらの方法)Vueより手間が多くなり、複雑なアニメーションはめんどくさそうだ
ReactHooksで作成する
Reactでは、ClassComponentが滅びてReactHooksとかいうのを使うのがスタンダードになるらしいのでこいつのもついでに作ったが、結構めんどくさかった
ReactにはFunctinalComponentとClassComponentがあって、ClassComponentでしかstateが利用できなかったが、FunctinalComponentでもReactHooksを利用してstateを扱えるようになったらしいSee the Pen ReactHooksHambergur by Saito Takashi (@7_asupara) on CodePen.
ReactHooksimport React from 'react'; function App() { const size = 50; const speed = 0.4; const strokeWidth = 6; const halfSize = size / 2; const halfStrokeWidth = strokeWidth / 2; const topLimit = halfStrokeWidth; const bottomLimit = size - halfStrokeWidth; const viewbox = `0 0 ${size} ${size}`; const stroke = 'black' // React.useStateで、stateのgetterとsetterの定義 // const [getter, setter] = React.useState(デフォルト値) const [closeFlg, setCloseFlg] = React.useState(false); const [clicked, setClicked] = React.useState(null); // useRefでDOMノードの取得 ClassComponentとだいたい同じ const line1Ref = React.useRef(null); const line2Ref = React.useRef(null); const line3Ref = React.useRef(null); // クリックイベント closeFlgをトグルするだけ const toggle = () => { setCloseFlg(!closeFlg); }; // useEffect SideEffect(副作用?)を実行するやつ React.useEffect(() => { if (closeFlg) { TweenMax.to(line1Ref.current, speed, { attr: { y1: bottomLimit }, ease: Expo.easeIn }) TweenMax.to(line2Ref.current, speed, { attr: { x1: size }, ease: Expo.easeIn}) TweenMax.to(line3Ref.current, speed, { attr: { y1: topLimit }, ease: Expo.easeIn }) } else { TweenMax.to(line1Ref.current, speed, { attr: { y1: topLimit }, ease: Expo.easeIn }) TweenMax.to(line2Ref.current, speed, { attr: { x1: 0 }, ease: Expo.easeIn}) TweenMax.to(line3Ref.current, speed, { attr: { y1: bottomLimit }, ease: Expo.easeIn }) } }, [closeFlg]); return ( <div className="button" onClick={toggle}> <svg viewBox={viewbox} width={size} height={size} style={{ overflow: 'visible' }}> <line ref={line1Ref} x1="0" y1={topLimit} x2={size} y2={topLimit} stroke={stroke} strokeWidth={strokeWidth} /> <line ref={line2Ref} x1="0" y1={halfSize} x2={size} y2={halfSize} stroke={stroke} strokeWidth={strokeWidth} /> <line ref={line3Ref} x1="0" y1={bottomLimit} x2={size} y2={bottomLimit} stroke={stroke} strokeWidth={strokeWidth} /> </svg> </div> ); }Refの使い方とstateをsetしないといけないのはだいたいClassComponentと同じ
ただ、クリックイベントを普通にFunctionalComponentのメソッドとして定義してもライフサイクルが考慮されないのか、そこでDOMノードにアクセスしてTweenしようとしても何も実行されない(画面にレンダリングされる前に定義されるからかな?よくわからん)
ReactHooksでは、useEffectとかいうのがComponentのライフサイクル(ClassComponentでいうcomponentDidMountとかcomponentDidUpdateとか)を管理しているみたいなので、これを利用する
クリックイベントには、stateのcloseFlgのトグル処理のみ記述し、
useEffectで実行したい処理(第一引数)と監視するstate(第二引数)を指定し、closeFlgが変更されたら実行されるようにする(componentDidUpdateにあたるかな, VueならWatcher使えば同じような実装になるような)アニメーションに限定していえば、慣れたらいけるかもやけど全然直感的じゃないのでめんどくさく感じたし、useEffectがなんか慣れない
まとめ
両者を比較すると、Vueに比べてReactはデータを厳格に扱うことを目指していると思われる
その分、Vueは今回の場合に限らず直感的にコードが書けると思うページ数が小規模でアニメーションが多めのインタラクティブなLP、コーポレートサイトが作りたければVueを使うべきだと思う
一方Reactは、大規模なシステム等でデータを厳格に扱いたい場合は優位だと思う
これらの中間のものは好きな方を勝手に選ぼうただ、今回はsvgのTweenでアニメーションしたので違いがでたが、CSSとか代替の方法もあると思うので楽な方法を検討すればいい
- 投稿日:2019-10-21T16:25:14+09:00
✨ TypeScript向けのシンプルなFirestoreライブラリを作った (web/admin/React Hooks対応)
@yarnaimo です ✋
最近 RSS と Twitter から情報を収集するツールを作っているのですが、その副産物として TypeScript 向け Firestore ライブラリ BlueSpark を作ったので紹介します。
追記:
decode
/decodeQuerySnapshot
を使わず、新しく追加したgetDoc
/getCollection
を使う方法に書き換えました。これはなに
TypeScript の Firestore ライブラリはすでにいくつか存在していますが、対応しているのが web と admin のどちらか片方だけだったり、
Timestamp
とDate
など読み取りと書き込みの際で型が異なるフィールドの扱いが難しいといった課題がありました。BlueSpark は内部で行う処理を最小限にとどめ、ドキュメントの参照/クエリの作成などは今まで通りネイティブ SDK で行う形にしたことで、これらの課題をクリアしつつ覚えやすい設計になっています。
yarnaimo/bluespark > https://github.com/yarnaimo/bluespark
TypeScript向けFirestoreライブラリ BlueSparkを作りました!!?
— やまいも (@yarnaimo) October 20, 2019
✅ web / admin どちらにも対応
✅ シンプルなモデル定義
✅ 読み取り/書き込みの型を分けられる (読み取りはTimestamp / 書き込みはDate | FieldValue など)
yarn add bluespark でインストールできます!https://t.co/gAiyR1bcIn pic.twitter.com/QV1RhDBlpY1. インストール
yarn add bluespark # or npm i -S bluespark2. 準備
i. 初期化
まず
initializeApp()
で初期化します。(admin の場合は必要に応じて読み替えてください)import firebase, { firestore } from 'firebase/app' import 'firebase/firestore' import { Blue, Spark } from 'bluespark' const app = firebase.initializeApp({ apiKey: '### FIREBASE API KEY ###', authDomain: '### FIREBASE AUTH DOMAIN ###', projectId: '### CLOUD FIRESTORE PROJECT ID ###', }) const dbInstance = app.firestore()ii. コレクション定義
次にコレクションを定義します。これはなくてもいいですがこのほうが補完が効いて楽です。
const createCollections = <F extends Blue.Firestore>(instance: F) => { type C = ReturnType<F['collection']> type Q = ReturnType<F['collectionGroup']> return { posts: () => instance.collection('posts') as C, } } const collection = createCollections(dbInstance) const collectionAdmin = createCollections(dbInstanceAdmin) // admin の場合ここを変えるだけiii. モデル定義
Blue.Interface
で型を定義しモデルを作成します。
読み取りと書き込みで型が異なる場合はBlue.IO
を使います。例えばdate
フィールドで読み取りがTimestamp
、書き込みがDate | FieldValue
となる場合以下のようにします。
Blue.Timestamp
/Blue.FieldValue
などは web と admin の union type です。type IPost = Blue.Interface<{ number: number date: Blue.IO<Blue.Timestamp, Date | Blue.FieldValue> text: string tags: string[] }> const Post = Spark<IPost>()3. 使い方 (基本 API)
定義したコレクションとモデルを使ってデータの読み取り/書き込みをしていきます。
i. ドキュメントを取得する
ドキュメントの参照自体は通常の Firestore SDK で行い、それを
.getDoc()
に渡す形になっています。const post = await Post.getDoc(collection.posts().doc('doc-id'))この場合、
post
の型は以下のようになります。type _ = { _createdAt: Blue.Timestamp _updatedAt: Blue.Timestamp _id: string _ref: Blue.DocRef number: number date: Blue.Timestamp text: string tags: string[] }
_id
にはドキュメントの id、_ref
にはドキュメントのリファレンスが入ります。
_createdAt
と_updatedAt
については後ほど説明します。また、第 2 引数に関数を渡すと自動的にデータを変換することができます。
const post = await Post.getDoc(collection.posts().doc('doc-id'), data => ({ ...data, number: String(data.number), }))この場合、
post
のnumber
フィールドはstring
型になります。ii. コレクション/クエリを取得する
コレクション/クエリの場合は
.getCollection()
を使います。
結果は配列とMap
の両方で取得できます。const { array, map } = await Post.getCollection(collection.posts()) array[0] // => post map.get('doc-id') // => postiii. ドキュメントを作成する
ドキュメントの作成には
.create()
を使います。
_createdAt
と_updatedAt
フィールドにServerTimestamp
が自動的にセットされます。await Post.create(collection.posts().doc('doc-id'), { number: 17, date: firestore.FieldValue.serverTimestamp(), // または Date text: 'text', tags: ['a', 'b'], })iiii. ドキュメントを更新する
ドキュメントの更新には
.update()
を使います。
実行すると_updatedAt
フィールドが自動的に更新されます。await Post.update(collection.posts().doc('doc-id'), { text: 'new-text', })4. 使い方 (React Hooks)
BlueSpark は React Hooks でも簡単に使うことができます。この機能には
react-firebase-hooks
を使用しています。i. ドキュメントを取得する
useSDoc
を使います。
post
は上記の基本 API の例と同じ型です。
読み込み中の場合loading
にtrue
が、エラーの場合error
にError
が入ります。
基本 API と同じく、第 3 引数に関数を渡すとデータの変換ができます。const { data: post, loading, error } = useSDoc( Post, collection.posts().doc('doc-id'), )ii. コレクション/クエリを取得する
useSCollection
を使います。const { array, map, loading, error } = useSCollection(Post, collection.posts())まとめ
BlueSpark を使うと Firestore がより使いやすくなります。ぜひ試してみてください!!?
今後 Cloud Functions のonCall()
のデータの型付けにも対応する予定です。あとがき
近況報告です
宗教戦争 pic.twitter.com/W7Ox0FZjKF
— やまいも (@yarnaimo) August 24, 2019
- 投稿日:2019-10-21T16:02:03+09:00
styled-componentsを使っているときのStoryBook v5.2対応
StoryBook v5.2 では、内部でTypeScriptが使われるようになり、型定義を同梱するようになりました。
しかし、内部でemotionを使っているため、その型定義が参照されてしまいます。何が問題なのかというとこの部分です。emotion.d.tsdeclare module 'react' { interface DOMAttributes<T> { css?: InterpolationWithTheme<any> } } declare global { namespace JSX { /** * Do we need to modify `LibraryManagedAttributes` too, * to make `className` props optional when `css` props is specified? */ interface IntrinsicAttributes { css?: InterpolationWithTheme<any> } } }css propを拡張するので、styled-componentsなど他のCSSinJSをプロジェクトで使っている場合に型が競合します。
emotionの型定義を無効化する
そこで
emotion
の型定義を無効化する必要が出てきます。
tsconfigのpathsを使って、参照を変えましょう。tsconfig
{ "compilerOptions": { "baseUrl": ".", "paths": { "@emotion/core": ["src/types/emotion.d.ts"] }src/types/emotion.d.ts
declare module '@emotion/core';以上です。これでemotionの型定義が無効化され、今まで通りプロジェクトにあるstyled-componentsなどのCSSinJSの型が適用されます。
- 投稿日:2019-10-21T14:41:53+09:00
aws-amplifyの認証機能を実装してみる
注意: ?初心者です?♂️
React × amplifyを使ってログイン画面を実装する機会があったので、簡単に紹介したいと思います。
今日(2019/10/17)時点で既に、amplifyに関する本や記事がたくさんあるのを見かけるので、この記事は「AWS触ったことがないからコワイ」方を対象に、amplifyを知るキッカケにでもなって頂ければと思います。ログイン画面を作る前にやっておくこと
作業前に環境を整えておく必要があります。私よりも詳しく説明下さっている記事がたくさんありますので、私自身参考になったサイトを紹介します。
事前準備
①Node.jsのバージョンは8.X以上
②npmのバージョンは5.X以上
③amplify cliをグローバルインストール$ npm install -g @aws-amplify/cli④お持ちのAWSアカウントでIAMユーザーを作っておきます。(次のステップ、「amplify configure」で必要です。)
参考になった記事: AWS Amplify CLIの使い方〜インストールから初期セットアップまで〜
⑤configure
コマンドを進めると、AWS IAMダッシュボードが開くので、設定を進めていきます。
そこでaccessKeyIDやsecretAccessKey等も入力していきます。$ amplify configure
create-react-appでプロジェクト作成
create-react-appで適当な作業用プロジェクトを作成しておき、作成したフォルダ内でaws-amplify、aws-amplify-reactライブラリをインストールします!
$ npm install aws-amplify aws-amplify-reactまた、
$ amplify init
コマンドで環境変数を入力していきます。$ amplify init
実はこのあたりの操作、YouTubeでAWSスタッフの方がamplifyについて講義をされております。ぜひ観てみてください!
↓
【AWS Black Belt Online Seminar】AWS Amplify上手くいくとフォルダ内に
aws-exports.js
ファイルとamplify
フォルダができているのがわかると思います。簡単実装
上記までの事前準備が完了したら、ログイン周りの実装に入っていきます!amplifyはAuth認証APIを提供してくれてるので、まずは
$ amplify add auth
コマンドをし、色々聞かれるので選択していきます。
ちなみに今回は下のような設定で進めてます。YouTubeの説明の通りな感じです。Do you want to use the default authentication and security configuration? > Default configuration How do you want users to be able to sign in? > Username Do you want to configure advanced settings? > No, I am done.完了したら最後に
$ amplify push
コマンドをし、AWSのCognito画面上に新しくユーザープール、IDプールが作成されているのを確認してみてください!
(CognitoはAWSの認証サービスです。ユーザーの操作を簡単且つセキュアに扱うことができ、FacebookやGoogleアカウントとの連携も可能にしてくれます。詳細はAmazon Cognito)$ amplify push
AmplifyはUIも提供しているので、ここまで来たら最速で実装できる方法をご紹介します!
ここではwithAuthenticator
コンポーネントが登場します。
まずは前準備にsrc/index.js
ファイルにaws-amplifyライブラリと先ほど自動で生成されたaws-exports.js
をimportしていきます。import Amplify from 'aws-amplify' import config from './aws-exports' Amplify.configure(config)次にApp.jsファイルにaws-amplify-reactが提供している
withAuthenticator
コンポーネントを呼び出します。
今回は公式サイトで紹介されている、最小の設定を試してみることにします。(Amplify Javascript)... import { withAuthenticator } from 'aws-amplify-react' function App() { ... } export default withAuthenticator(App)これだけで下のようなログイン画面の出来上がりです!キャプチャは、新規でアカウントを作成し、メールに届いた仮パスワードを入力している画面です。
App.js内に下のようなサインアウトボタンを置いてみて、挙動を確認してみてください。
import { Auth } from "aws-amplify" <button onClick={() => { Auth.signOut() }}>Sign Out</button> // または下のようにsignOut関数を作成 const signOut = () => { Auth.signOut() .catch((error) => console.log('サインアウト失敗: ', error)) }合わせて、ユーザー情報も表示するようにしてみました。
aws-amplify
が提供しているAuth.currentUserPoolUser()
で取得することができます!const data = await Auth.currentUserPoolUser()
console.log()
するとお分かりかと思いますが、username
にはログイン中のユーザー名、attributes
にメールアドレスが入っています。電話番号の認証もしていればphone_number
も取得出来ます。余談ですが、トークン情報も欲しかったので、下のような関数を作って作成していました。
console.log
して取得出来ているのを確認してみください!const getLocalStorageIdToken = async () => { const resp = await Auth.currentSession() const accessToken = resp.getAccessToken().getJwtToken() console.log("トークン: ", accessToken) }触ってみて
敷居が高く感じられたAWSが、aws amplifyのお陰で大分親近感が湧いた感じがします。
ありがたや〜?✨
- 投稿日:2019-10-21T09:02:39+09:00
FirebaseとReact+TypeScriptの連携
FirebaseとReact+TypeScriptの連携
※同じ記事をこちらにも書いています
今回は以下の機能を利用します
- Firebase
- hosting 静的コンテンツの設置とfunctionsへのプロキシ
- functions databaseへの入出力
- database データ保存
Webからのアクセスはfunctionsも含め、必ずhostingを介するようにします。これで静的コンテンツとデータアクセス用の窓口のドメインが同一になるので、CORS対策で余計な処理を付け加える必要がなくなります。
Reactに関してはトランスコンパイル後のファイルを静的コンテンツとしてhostingに配置します。フロントエンドからFirebaseのdatabaseへのアクセスはfunctions経由となるので、フロントエンド側にAPIキーの設定をする必要はありません。今回はキーを一切使わないコードとなっています。
1.基本設定
1.1 Firebaseツールのインストール
Node.jsが入っていることが前提です
以下のコマンドで必要なツールをインストールしますnpm -g i firebase-tools1.2 Firebase上にプロジェクトを作成
2019/10/18 17:16:45
https://console.firebase.google.com/へ行って、プロジェクトを作成します。
コマンドラインから作ることも可能ですが、IDが被った場合に対処しにくいので、Web上から作った方が簡単です。ローカルに開発環境を作る
まずは新規ディレクトリを作って、そこをカレントディレクトリにしてください
・Firebaseにログインし、コマンドからの操作をするための権限を取得する
firebase login・質問が出たらエンターキー
? Allow Firebase to collect CLI usage and error reporting information?・ブラウザからユーザ認証
・Firebaseプロジェクトの作成
firebase init ? Are you ready to proceed? (Y/n) <- エンターキー・Database、Functions、Hostingを選ぶ
? Which Firebase CLI features do you want to set up for this folder? Press Space to select features, then Enter to confirm your choices. (Press <space> to select, <a> to toggle all, <i> to invert selection) (*) Database: Deploy Firebase Realtime Database Rules ( ) Firestore: Deploy rules and create indexes for Firestore (*) Functions: Configure and deploy Cloud Functions (*) Hosting: Configure and deploy Firebase Hosting sites ( ) Storage: Deploy Cloud Storage security rules・プロジェクトの選択は先ほど作ったプロジェクトを選ぶ
? Please select an option: (Use arrow keys) > Use an existing project <- これを選択・データベースのルール
? What file should be used for Database Rules? database.rules.json <- エンターキー・開発言語の選択
TypeScript・TSLintはNo
・残りは全てエンターキー
1.3 React環境の構築
package.jsonの作成
npm -y initReact環境構築パッケージのインストール
npm -D i setup-template-firebase-react必要パッケージ類のインストールを確定させる
npm iReactのビルド(自動ビルドの場合はnpm run watch)
npm run buildインストールしたパッケージは以下のような構造を作ります
root/ ├ public/ (ファイル出力先) └ front/ (フロントエンド用ディレクトリ) ├ public/ (リソースHTMLファイル用) │ └ index.html ├ src/ (JavaScript/TypeScript用ディレクトリ) │ ├ .eslintrc.json │ ├ index.tsx │ └ tsconfig.json └ webpack.config.js1.4 firebaseエミュレータの起動
エミュレータの起動
(functionsがコンパイルされてないのでエラーを出しますが、ここでは無視してください)npm start確認
http://localhost:5000/「今日は世界!」と表示されればOK
2.掲示板を作る
2.1 functionsの設定
データが送られてきたらdatabaseに書き込み、そうでない場合もdatabase上のデータを返すプログラムです
作成が終わったらfunctionsディレクトリでnpm buildを行う必要があります/functions/src/index.tsimport * as functions from "firebase-functions"; import * as admin from "firebase-admin"; admin.initializeApp(functions.config().firebase); export const proc = functions.https.onRequest((request, response) => { (async () => { try { const { name, body } = request.body; if (name && body) { const date = new Date().toISOString(); await admin .database() .ref("/bbs") .push({ name, body, created_at: date, update_at: date }); } } catch {} })(); admin .database() .ref("/bbs") .orderByChild("created_at") .on("value", data => { const values = data!.val(); const result = Object.entries(values).map(([key, value]) => ({ ...value, id: key })); response.status(200).send(result); }); });2.2 hostingの設定
functionsで作成したコードがhostingを経由できるように、rewritesの設定を追加します
これをやったら、firebaseのemulaterを再起動する必要があります/firebase.json{ "database": { "rules": "database.rules.json" }, "functions": { "predeploy": [ "npm --prefix \"$RESOURCE_DIR\" run lint", "npm --prefix \"$RESOURCE_DIR\" run build" ], "source": "functions" }, "hosting": { "public": "public", "ignore": [ "firebase.json", "**/.*", "**/node_modules/**" ], "rewrites": [ { "source": "/proc", "function": "proc" }, { "source": "**", "destination": "/index.html" } ] } }2.3 フロントエンド側の作成
ここから先はReactのフロントエンドプログラムとなります
2.3.1 パッケージの追加インストール
フロントエンド側で状態管理や通信を行うためのモジュールを追加します
npm -D i react-redux @types/react-redux @jswf/redux-module @jswf/adapter2.3.2 ソースコードの追加
・Storeデータ管理とFirebaseとの通信機能の実装
/front/src/MessageModule.tsimport { ReduxModule } from "@jswf/redux-module"; import { Adapter } from "@jswf/adapter"; //メッセージの構造 interface Message { id: number; name: string; body: string; created_at: Date; updated_at: Date; } //Storeで使う構造 interface State { messages: Message[]; } //データモジュールの定義 export class MessageModule extends ReduxModule<State> { protected static defaultState: State = { messages: [] }; public write(message: { name: string; body: string }) { Adapter.sendJsonAsync("proc", message).then(e => { if (e instanceof Array) this.setState({ messages: e as Message[] }); }); } public load() { Adapter.sendJsonAsync("proc").then(e => { if (e instanceof Array) this.setState({ messages: e as Message[] }); }); } }・入力フォーム
/front/src/MessageForm.tsximport { useRef } from "react"; import React from "react"; import { useModule } from "@jswf/redux-module"; import { MessageModule } from "./MessageModule"; export function MessageForm() { const messageModule = useModule(MessageModule, undefined, true); const message = useRef({ name: "", body: "" }).current; return ( <div> <div> <button onClick={() => { messageModule.write(message); }} > 送信 </button> </div> <div> <label> 名前 <br /> <input onChange={e => (message.name = e.target.value)} /> </label> </div> <div> <label> メッセージ <br /> <input onChange={e => (message.body = e.target.value)} /> </label> </div> </div> ); }・メッセージ表示
/front/src/MessageList.tsximport React, { useEffect } from "react"; import { useModule } from "@jswf/redux-module"; import { MessageModule } from "./MessageModule"; export function MessageList() { const messageModule = useModule(MessageModule); //初回のみメッセージを読み込む useEffect(() => { messageModule.load(); }, []); //メッセージをStoreから取得 const messages = messageModule.getState("messages")!; return ( <div> {messages.map(msg => ( <div key={msg.id}> <hr /> <div> [{msg.name}]{msg.created_at} -- ({msg.id}) </div> <div>{msg.body}</div> </div> ))} </div> ); }・トップモジュール
/front/src/index.tsximport React from "react"; import * as ReactDOM from "react-dom"; import { Provider } from "react-redux"; import { createStore } from "redux"; import { ModuleReducer } from "@jswf/redux-module"; import { MessageForm } from "./MessageForm"; import { MessageList } from "./MessageList"; function App() { return ( <> <MessageForm /> <MessageList /> </> ); } const store = createStore(ModuleReducer); ReactDOM.render( <Provider store={store}> <App /> </Provider>, document.getElementById("root") as HTMLElement );2.3.3 確認
データはemulaterを再起動すると消えます
emulaterの起動
npm start2.3.4 確認
http://localhost:5000/以下のように表示されます
3.デプロイ
以下のコマンドを使ってしばらく待ちます
npm deployHosting URLとして表示されたアドレスで、掲示板が表示されます
初回は書き込みに15秒くらいかかりますが、その後はすぐに応答するようになります4.まとめ
Firebaseへのアクセスはfunctionsを経由すると、フロントエンド側でキーの管理をする必要がなくなるので、とてもやりとりが楽になります。さらに全てをいったんhosting経由にすることによって、ドメインが分散しなくなり、開発環境と本番環境でAjaxでのアクセス先を調整する必要も無くなりました。こんなに色々出来るのに、これらが全部無料で使えるとは素晴らしい時代になったと思いました。
- 投稿日:2019-10-21T02:01:04+09:00
ReactでブラウザーのStreams APIを使って、ダウンロードプログレスを表示する
Streams APIがブラウザーで使えるようになってからしばらく経つけど、Reactとの相性はどうだ?個人プロジェクトに導入するとき、試行錯誤した結果をここに投稿する。
TL;DR
細かい処理が多くて、抽象化してカスタムなHookができたので、以下のGistからコピペできる
https://gist.github.com/jlkiri/bc0a9bbf5d81c6f8bbe1cfd59a106380また、その動きが確認できるデモが以下のリンクでアクセスできる(12MBの宇宙の画像をダウンロードする)
https://fetch-stream-hook-demo.jlkiri.now.sh/
(Githubレポジトリ: https://github.com/jlkiri/fetch-stream-hook-demo)注意点
結論から言うと、MDNに乗っている例をそのまま使えばいいのだが、いくつか気を付けるべき点がある。
レンダリング
普段、Reactでデータを取得する場合、ローディングステート(つまりデータがまだ取得されていない状態)と、取得が終わった状態を分けて表示する。一方で、Streams APIのおかけで取得がチャンクごとに制御できるので、途中結果もすべて表示できる。
しかし、ダウンロードされるチャンクごとにステートの更新をすれば、おそらくCPUの負荷が高くて、ほかに表示されている画面の部分の反応が悪くなる可能性がある。なので、この場合に限って、更新は、Reactに任せず
ref
を使ってするべき。以下で、取得したバイト数とファイルのバイト数合計を使って、何パーセントがダウンロードされたかを
ref
を使って直接表示する。(ref
の作成などは省略)fetch(url) .then(response => { const contentLength = response.headers.get("content-length"); let loaded = 0; const stream = new ReadableStream({ start(controller) { const reader = response.body.getReader(); return pump(); function pump() { return reader.read().then(({ done, value }) => { if (done) { controller.close(); return; } loaded += value.byteLength; progressRef.current.textContent = `${Math.round((loaded / contentLength) * 100)}%`; controller.enqueue(value); return pump(); }); } } }); return new Response(stream); }) .then(response => response.json()) .then(data => console.log(data));ファイルサイズ
ファイルサイズが大きい場合は、ユーザーにダウンロードを止める手段を提供しないといけない。AbortControllerの
abort()
関数を使えば、「止める」ボタンなどが簡単に実装できる。さらに、
navigator.connection.effectiveType
でユーザーの通信環境がわかるので、取得前に途中プログレスを表示するか、しないかの判断もできる。長くなりそうな場合だけプログレスを表示する選択肢ができる。その他
サーバーと通信方式によって
Content-Length
ヘッダーが欠けている場合も考えられる。たとえば、HTTP/1.1でTransfer-Encoding: chunked
を使うと、Content-Length
ヘッダーがない。その場合、自分で合計のバイト数をあらかじめ確認して定数にするか、処理をあきらめるなどの対策が必要になる。
- 投稿日:2019-10-21T00:06:18+09:00
React HookでオブジェクトをuseStateではなくuseReducerで管理する
概要
setStateでオブジェクトを管理していて起きた予期せぬ動作について調べてみたので記事として残しておきます。
TL;DR
React Hook
において、useState
で複雑なオブジェクトを扱うのは最適解ではないのでuseReducer
を使用する。フック API リファレンスより引用
クラスコンポーネントの setState メソッドとは異なり、useState は自動的な更新オブジェクトのマージを行いません。
別の選択肢としては useReducer があり、これは複数階層の値を含んだ state オブジェクトを管理する場合にはより適しています。
useState
ではプリミティブ型、またはプリミティブ型からなるオブジェクトを扱い、それ以外のオブジェクトにはuseReducer
を使用する。詳細
Next.js
でdiv
要素、input
要素にダブルクリックでトグルさせるコンポーネントを作成していました。コンポーネントの設計として下記の様なステートを
type ElementState = { selected: boolean element: JSX.Element } const [elementState, setElementState] : [ ElementState, React.Dispatch<React.SetStateAction<ElementState>> ] = React.useState<ElementState>({ selected: false, element: <div onDoubleClick={onDoubleClick}> {text} </div> })ダブルクリックイベントハンドラでトグルしたかったのですが
const onDoubleClick = () => { if (elementState.selected) { setElementState({ selected: false, element: <div onDoubleClick={onDoubleClick}> {text} </div> }) } else { setElementState({ selected: true, element: <input onDoubleClick={onDoubleClick} defaultValue={text} /> }) } }
elementState.element
は更新され、elementState.selected
が更新されずトグルが実現できませんでした。リンク1を参照した所
useState
では複雑なオブジェクトを更新するのには向かず、それらを処理したい場合にはuseReducer
を使用するという結論を得ました。少し話は逸れますが、なぜ片方のプロパティが更新され、他方は更新されないのか?という点については結論はまだ出ていません。
今回の扱うオブジェクトは
boolean
とJSX.Element
からなるオブジェクトです。
あくまで推測ですが、プリミティブ型とオブジェクト型が混合している場合、動作が未定義になるということかもしれません。試しに
string
、boolean
の場合で試してみた場合、期待通りプロパティは全て正常に更新されました。上記のような予期せぬ動作は
useReducer
を使用することで回避できます。まずは
reducer
を定義しinterface State { element: JSX.Element selected: boolean } const reducer = (state: State, selected: boolean): State => { if (selected) { return { selected: false, element: <div onDoubleClick={() => dispatch(false)}> {text} </div> } } else { return { selected: true, element: <input onDoubleClick={() => dispatch(true)} defaultValue={text} /> } } }
reducer
を登録し、state
とdispatch
を生成してあげれば大丈夫です。const [state, dispatch] : [ State, React.Dispatch<boolean> ] = React.useReducer(reducer, { selected: false, element: <div onDoubleClick={() => dispatch(state.selected)}> {text} </div> })
reducer
の概念は処理の流れを決定するデザインパターンだと認識していましたが、React Hook
では複雑なオブジェクトを扱う場合はuseReducer
に従えということなのでしょうか。