20191021のReactに関する記事は9件です。

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 update

Webpackerを除く

Gemfileからwebpackerを除きます。

React表示用のviewを作成

bundle exec rails g controller react-ui::home index

tsconfigを作成

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 -D

webpack記載

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 -D

webpack.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です。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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.

Quick Start・React Redux より引用

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 typeaction 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.js
import { 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.jsx
import 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.js
import { 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 として適当な形に整形する関数です。
actionstore に渡すことを dispatch と呼び、それをする関数のことを dispatcher と呼びます。)

connect()() は、Redux の state と React component を接続する関数です。
mapStateToProps ないし mapDispatchToProps と React component を渡すと、container component が返ってきます。

これで Game component は GameContainer component から Redux の諸々を props 経由で受け取ることができるようになりました。
なので、それらを使うよう書き換えましょう。

src/components.jsx
export 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.jsx
import 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:

Usage with React・Redux より引用

src/index.jsx
import 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 statedispatcher を良い感じに渡してくれます。
これで三目並べに Redux を導入できたはずです。

ここに、以下のような変更を加えても良いでしょう。

  • calculateWinner()src/utils.js に切り出す
  • 全ての React component を functional component にする
  • Game component の render() 内で定義されている current および statusmapStateToProps() 内に移す
  • Board component に渡されている onClick() および squares props を Redux の state から直接受け取るようにする

これで Redux の導入が完了しました。
ちなみに、導入の全容はこのリポジトリにあります。

更なる発展

プロダクトとして使うには(場合によりますが)まだ不十分です。
例えば以下のようなものが必要でしょう。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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.

React
import 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.

ReactHooks
import 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とか代替の方法もあると思うので楽な方法を検討すればいい

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

✨ TypeScript向けのシンプルなFirestoreライブラリを作った (web/admin/React Hooks対応)

@yarnaimo です ✋

最近 RSS と Twitter から情報を収集するツールを作っているのですが、その副産物として TypeScript 向け Firestore ライブラリ BlueSpark を作ったので紹介します。

追記: decode / decodeQuerySnapshot を使わず、新しく追加した getDoc / getCollection を使う方法に書き換えました。

これはなに

TypeScript の Firestore ライブラリはすでにいくつか存在していますが、対応しているのが web と admin のどちらか片方だけだったり、TimestampDate など読み取りと書き込みの際で型が異なるフィールドの扱いが難しいといった課題がありました。

BlueSpark は内部で行う処理を最小限にとどめ、ドキュメントの参照/クエリの作成などは今まで通りネイティブ SDK で行う形にしたことで、これらの課題をクリアしつつ覚えやすい設計になっています。

yarnaimo/bluespark > https://github.com/yarnaimo/bluespark

1. インストール

yarn add bluespark
# or
npm i -S bluespark

2. 準備

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),
}))

この場合、postnumber フィールドは string 型になります。

ii. コレクション/クエリを取得する

コレクション/クエリの場合は .getCollection() を使います。
結果は配列Map の両方で取得できます。

const { array, map } = await Post.getCollection(collection.posts())

array[0] // => post
map.get('doc-id') // => post

iii. ドキュメントを作成する

ドキュメントの作成には .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 の例と同じ型です。
読み込み中の場合 loadingtrue が、エラーの場合 errorError が入ります。
基本 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() のデータの型付けにも対応する予定です。

あとがき

近況報告です

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

styled-componentsを使っているときのStoryBook v5.2対応

StoryBook v5.2 では、内部でTypeScriptが使われるようになり、型定義を同梱するようになりました。
しかし、内部でemotionを使っているため、その型定義が参照されてしまいます。何が問題なのかというとこの部分です。

https://github.com/emotion-js/emotion/blob/1bb3efe399ddf0f3332187f3c751fbba9326d02c/packages/core/types/index.d.ts#L82-L99

emotion.d.ts
declare 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の型が適用されます。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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)

これだけで下のようなログイン画面の出来上がりです!キャプチャは、新規でアカウントを作成し、メールに届いた仮パスワードを入力している画面です。

1.gif

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のお陰で大分親近感が湧いた感じがします。
ありがたや〜?✨

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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-tools

1.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 init

React環境構築パッケージのインストール

npm -D i setup-template-firebase-react

必要パッケージ類のインストールを確定させる

npm i

Reactのビルド(自動ビルドの場合はnpm run watch)

npm run build

インストールしたパッケージは以下のような構造を作ります

root/  
 ├ public/ (ファイル出力先)  
 └ front/ (フロントエンド用ディレクトリ)  
  ├ public/ (リソースHTMLファイル用)  
  │ └ index.html  
  ├ src/ (JavaScript/TypeScript用ディレクトリ)  
  │ ├ .eslintrc.json  
  │ ├ index.tsx  
  │ └ tsconfig.json  
  └ webpack.config.js  

1.4 firebaseエミュレータの起動

エミュレータの起動
(functionsがコンパイルされてないのでエラーを出しますが、ここでは無視してください)

npm start

確認

http://localhost:5000/

「今日は世界!」と表示されればOK

2.掲示板を作る

2.1 functionsの設定

データが送られてきたらdatabaseに書き込み、そうでない場合もdatabase上のデータを返すプログラムです
作成が終わったらfunctionsディレクトリでnpm buildを行う必要があります

/functions/src/index.ts
import * 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/adapter

2.3.2 ソースコードの追加

・Storeデータ管理とFirebaseとの通信機能の実装

/front/src/MessageModule.ts
import { 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.tsx
import { 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.tsx
import 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.tsx
import 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 start

2.3.4 確認

http://localhost:5000/

以下のように表示されます

image.png

3.デプロイ

以下のコマンドを使ってしばらく待ちます

npm deploy

Hosting URLとして表示されたアドレスで、掲示板が表示されます
初回は書き込みに15秒くらいかかりますが、その後はすぐに応答するようになります

4.まとめ

 Firebaseへのアクセスはfunctionsを経由すると、フロントエンド側でキーの管理をする必要がなくなるので、とてもやりとりが楽になります。さらに全てをいったんhosting経由にすることによって、ドメインが分散しなくなり、開発環境と本番環境でAjaxでのアクセス先を調整する必要も無くなりました。こんなに色々出来るのに、これらが全部無料で使えるとは素晴らしい時代になったと思いました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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));

ファイルサイズ

ファイルサイズが大きい場合は、ユーザーにダウンロードを止める手段を提供しないといけない。AbortControllerabort()関数を使えば、「止める」ボタンなどが簡単に実装できる。

さらに、navigator.connection.effectiveTypeでユーザーの通信環境がわかるので、取得前に途中プログレスを表示するか、しないかの判断もできる。長くなりそうな場合だけプログレスを表示する選択肢ができる。

その他

サーバーと通信方式によってContent-Lengthヘッダーが欠けている場合も考えられる。たとえば、HTTP/1.1でTransfer-Encoding: chunkedを使うと、Content-Lengthヘッダーがない。その場合、自分で合計のバイト数をあらかじめ確認して定数にするか、処理をあきらめるなどの対策が必要になる。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

React HookでオブジェクトをuseStateではなくuseReducerで管理する

概要

setStateでオブジェクトを管理していて起きた予期せぬ動作について調べてみたので記事として残しておきます。

TL;DR

React Hookにおいて、useStateで複雑なオブジェクトを扱うのは最適解ではないのでuseReducerを使用する。

フック API リファレンスより引用

クラスコンポーネントの setState メソッドとは異なり、useState は自動的な更新オブジェクトのマージを行いません。

別の選択肢としては useReducer があり、これは複数階層の値を含んだ state オブジェクトを管理する場合にはより適しています。

useStateではプリミティブ型、またはプリミティブ型からなるオブジェクトを扱い、それ以外のオブジェクトにはuseReducerを使用する。

詳細

Next.jsdiv要素、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を使用するという結論を得ました。

少し話は逸れますが、なぜ片方のプロパティが更新され、他方は更新されないのか?という点については結論はまだ出ていません。

今回の扱うオブジェクトはbooleanJSX.Elementからなるオブジェクトです。
あくまで推測ですが、プリミティブ型とオブジェクト型が混合している場合、動作が未定義になるということかもしれません。

試しにstringbooleanの場合で試してみた場合、期待通りプロパティは全て正常に更新されました。

上記のような予期せぬ動作は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を登録し、statedispatchを生成してあげれば大丈夫です。

const [state, dispatch] : [
    State,
    React.Dispatch<boolean>
] = React.useReducer(reducer, {
    selected: false,
    element: <div onDoubleClick={() => dispatch(state.selected)}>
        {text}
    </div>
})

reducerの概念は処理の流れを決定するデザインパターンだと認識していましたが、React Hookでは複雑なオブジェクトを扱う場合はuseReducerに従えということなのでしょうか。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む