20190714のReactに関する記事は7件です。

GatsbyでNetlifyのFormを使う(react-bootstrap使用)

Gatsbyで作ったサイト(react-bootstrap使用)でNetlifyのFormを使おうとして微妙にハマったのでメモしておく。
react-bootstrapを使わない場合も参考になると思う。

環境

  • React 16.8.6
  • Gatsby 2.1
  • react-bootstrap 1.0.0-beta.9

コード

以下のコードで動作

class ContactForm extends Component {
  constructor() {
    this.state = { validated: false }
  }

  // react-bootstrap用のPOSTイベントハンドラ
  // https://react-bootstrap.github.io/components/forms/#forms-validation
  handleSubmit(event) {
    if (!event.currentTarget.checkValidity()) {
      event.preventDefault()
      event.stopPropagation()
    }
    this.setState({ validated: true })
  }

  render (){
    return (
      <Form validated={this.state.validated} onSubmit={this.handleSubmit} name="contact" method="POST" data-netlify="true">
        <input type="hidden" name="form-name" value="contact" />
        <Form.Group>
          <Form.Label>name</Form.Label>
          <Form.Control name="name" type="text" />
        </Form.Group>
        <Button type="submit" >送信</Button>
      </Form>
    )
  }
}

ポイント

まずは、netlifyの公式通りにFormタグにname="contact"及びdata-netlify="true"プロパティを追加する。ちゃんとreact-bootstrapでも動作した。

次に、Gatsbyの場合、Netlify側が挿入する以下のタグがGatsbyによって消去されるため、予め入力しておく必要がある。ちなみに、この設定方法は公式ブログに書いてある。

<input type="hidden" name="form-name" value="contact" />

また、<Form.Control />(普通のhtmlの場合は<input />)にはnameプロパティが必要。namevalueプロパティの値が、ユーザーへ通知される仕組み。

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

ReactアプリをFirebaseにのっける最も簡単な方法

ReactとFirebaseの組み合わせで使用できる、ちょっとしたマイナーツールを紹介。

generator-react-firebase - npm

Yeoman generator for starting projects using React and Firebase (Redux optional)

Yeomanとは

聞きなれない単語がありますが、ただの形容詞ではなく、yeoman.ioのことです。
辞書で引いてみると、

1《主に英国で用いられる》
    a《あまり用いられない語》 自作農,小地主.
    b(昔の)自由民,ヨーマン 《独立自営農民; gentleman より低い地位の自由所有権保有者 (freeholder)》.
    c(昔,王家・貴族に仕えた高位の)従者.
2【米海軍】 事務係下士官.

よしなにいい感じに気を利かして作業をやってくれますよ、といったところでしょうか。
本家サイトによれば、scaffolding tool, build tool, package managerを含むエコシステムをいい感じに提供してくれるらしいです。イケてそうですね。

この中で、scaffolding toolとかいうのが気になるやつです。
これはgeneratorを作るgeneratorであり、generator-react-firebaseyeomanでscaffoldされています。

yeomanを使って作られたgeneratorは、他にもたくさんあります。

vscode-generator
hyperledger-composer
などなど。

それ以外のgenerator: https://yeoman.io/generators/

generator-react-firebaseとは

web-app-generatorの一つであり、react-redux-firebaseを使用してアプリをscaffoldします。

本来であれば、フロントエンドのイケイケ開発を始めるには、深く広い(webpackやgulp他様々の)知識が必要になります。
generatorを使えばそこをさっと飛び越えてしまえるのが、イケイケポイントですね。

generator-react-firebaseの使い方

Readmeに書かれている通り、yoを実行して、あとは質問に答えていくだけです。

npm install -g yo generator-react-firebase

yo react-firebase

Screen Shot 2019-07-14 at 21.20.13.png

予めfirebase loginとかしておけば、イイカンジにレコメンドしてくれるので、とても簡単にreactアプリをscaffoldできます。

注意点

コンソールで打ち込む情報の中に、FirebaseプロジェクトのApiKeyが含まれます。
これはシークレットなので隠すべき(公開レポジトリにpushしてはいけない)ですが、.gitignoreに登録されていないファイル.yo-rc.jsonに紛れ込みます。お気をつけて。

ちなみに僕はpushしてしまって、GG君に怒られました。
GG!!
Screen Shot 2019-07-14 at 20.50.30.png

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

ReactでHTMLを作成し、Clickした際にそれぞれの情報をモーダルに表示・非表示させる(Railsで)

はじめに

ReactでHTMLを作成するまでは案外すんなりと行ったのですが、クリックした際に「それぞれの情報を持たせてモーダルを表示する」ことに苦労したので忘備録としてまとめておきます。
最終的にはこのような感じになります。
たくさんの方法を試したので不必要なものもあるかもしれません。
その点は指摘していただけると幸いです。
下の画像をクリックした際にその情報を表示させています。
表示させる内容は個人で変更してください。
react modal.jpg
876747d1b80a09753cfdb7b53b817923.jpg

1.gemの導入とインストール

この2つのgemをGemfileに加え、bundle installしてください

Gemfile
gem 'react-rails'
gem 'webpacker'

その後、それぞれインストールしてください

$ bundle install
$ rails webpacker:install  
$ rails webpacker:install:react 
$ rails generate react:install

するとapp以下にapp/javascript/componentsフォルダが作成されます。
私自身、app/assets以下にJavascriptフォルダがあるのに大丈夫なのかと思いましたが、問題ありません。

2. application.html.hamlにtagを追加

application.html.hamlに以下の記述を加えてください

application.html.haml
= javascript_pack_tag 'application'

この際にTerminalでyarnがどうこうというエラーが出るかもしれません。
その場合$ yarn install等、各自で調べて解決してください。

これらでRails上でReactを使う準備ができましたので、いよいよjsファイルに記述していきます。

3. Components以下にjsファイルの作成

Components以下にApp.jsGraduate.jsを作成します。
これらの名前は自分で決めてくださって結構です。
ではこちらのコードをコピーしてください

App.js
import React from 'react';
import Graduate from './Graduate';

const lessonList = [
  {
    name: '開成太郎(2017)',
    school: '一条高校',
    image: 'https://cdn.pixabay.com/photo/2018/01/03/12/33/graduation-3058263__480.jpg',
    introduction: '中学一年生の時は全然テストの点数を取れなかったけど、ここに通い始めて2ヶ月ほどで目に見えて点数が上がるようになりました',
  },
  {
    name: '開成花子(2016)',
    school: '奈良高校',
    image: 'https://image.shutterstock.com/image-photo/japanese-student-browse-job-information-260nw-1010242525.jpg',
    introduction: '学校の授業では物足りず、塾に通うことに決めました。塾では私に合わせた応用問題を別途用意してもらい、そのおかげで合格できました',
  },
  {
    name: '開成三郎(2019)',
    school: '郡山高校',
    image: 'https://image.shutterstock.com/image-photo/high-school-student-graduation-260nw-1242927238.jpg',
    introduction: '土曜日や日曜日も朝早くから開塾してくれたおかげでたくさんの勉強時間を確保することができました。',
  },
  {
    name: '開成四郎(2015)',
    school: '登美ケ丘高校',
    image: 'https://cdn.pixabay.com/photo/2017/02/05/00/08/graduation-2038864__480.jpg',
    introduction: '二年生まで部活一筋だったため、勉強の進捗度は他の人に比べて劣っていたけれど、その分個別に補習の時間を組んでくれたおかげで合格できました。',
  }
];

class App extends React.Component {
  render() {
    return (
        <div className="performance">
        {lessonList.map((lessonItem) => {
      return (
          <Graduate
            name={lessonItem.name}
            image={lessonItem.image}
            school={lessonItem.school}
            introduction={lessonItem.introduction}
            />
      );
    })}
      </div>
    );
  }
}

export default App;

それでは解説していきます。

import React from 'react';
import Graduate from './Graduate';

まずimportとは輸入という意味です。
その名の通り、一行目ではReactを二行目ではGraduateファイルを読み込んでいます。
このおかげでReactを使うことができ、またGraduateにパラメーター(props)を渡すことができます。

const lessonList = [
  {
    name: '開成太郎(2017)',
    school: '一条高校',
    image: 'https://cdn.pixabay.com/photo/2018/01/03/12/33/graduation-3058263__480.jpg',
    introduction: '中学一年生の時は全然テストの点数を取れなかったけど、ここに通い始めて2ヶ月ほどで目に見えて点数が上がるようになりました',
  },
  {
    name: '開成花子(2016)',
    school: '奈良高校',
    image: 'https://image.shutterstock.com/image-photo/japanese-student-browse-job-information-260nw-1010242525.jpg',
    introduction: '学校の授業では物足りず、塾に通うことに決めました。塾では私に合わせた応用問題を別途用意してもらい、そのおかげで合格できました',
  },
  {
    name: '開成三郎(2019)',
    school: '郡山高校',
    image: 'https://image.shutterstock.com/image-photo/high-school-student-graduation-260nw-1242927238.jpg',
    introduction: '土曜日や日曜日も朝早くから開塾してくれたおかげでたくさんの勉強時間を確保することができました。',
  },
  {
    name: '開成四郎(2015)',
    school: '登美ケ丘高校',
    image: 'https://cdn.pixabay.com/photo/2017/02/05/00/08/graduation-2038864__480.jpg',
    introduction: '二年生まで部活一筋だったため、勉強の進捗度は他の人に比べて劣っていたけれど、その分個別に補習の時間を組んでくれたおかげで合格できました。',
  }
];

この部分ではそれぞれのハッシュを配列に入れています。これをあとでmapメソッドで一つずづ表示させていきます。

class App extends React.Component {
  render() {
    return (
        <div className="performance">
        {lessonList.map((lessonItem) => {
      return (
          <Graduate
            name={lessonItem.name}
            image={lessonItem.image}
            school={lessonItem.school}
            introduction={lessonItem.introduction}
            />
      );
    })}
      </div>
    );
  }
}

この部分でComponentを作成しています。(extendsは広げるという意味)
ConmonentはJavascriptの関数のようなものです。
その中のreturnでHTMLを返し、表示させています。またこのような記法をJSXと言います。
また、JSXには約束事があり、複数の要素を返すことができません。なので図の場合、performanceクラスを親要素としその中に色々と要素を追加しています。

{lessonList.map((lessonItem)ではlessonListの数だけ繰り返し、returnを読み込んでいます。
return内でとすることでGraduateコンポーネントを呼び出しています。これが可能なのはimport Graduate from './Graduate';のおかげです。
sosite
呼び出す際にはname,school,image,introductionのパラメーターを渡しています。(props)
このパラメーターをGraduate.js側で使うわけです。ちなみにJSX内でjavascriptの記法を用いる際は中括弧が必要なため、中括弧内に書いてあります。

export default App;

App.jsの最後の行ですが、exportとは輸出という意味です。
これのおかげでHTMLファイルでApp.jsを呼び出すことができ、returnとして要素をHTMLに追加することができます。App.jsがGraduateを二行目でimportできているのもGraduate.jsで最後にexport default Graduate;としているからです。

Graduate.js
import React from 'react';

class Graduate extends React.Component {
  constructor(props) {
    super(props);
    this.state = {isModalOpen: false};
  }

  handleClickLesson() {
    this.setState({isModalOpen: true});
  }

  handleClickClose() {
    this.setState({isModalOpen: false});
  }
  render() {
    let modal;
    if (this.state.isModalOpen) {
      modal = (
        <div className='modal-area'>
        <div className='modal-inner'>
          <div className='modal-header'></div>
          <div className='modal-introduction'>
            <h2>{this.props.name}</h2>
            <p>{this.props.introduction}</p>
          </div>
          <button
            className='modal-close-btn'
            onClick={() => this.handleClickClose()}
          >
            とじる
          </button>
        </div>
      </div>
      )
    };

    return (
      <div className="graduate">
          <img className="graduate__image" src={this.props.image}
               onClick={() => {this.handleClickLesson()}}
          />
          <div className="graduate__school">{this.props.school} 合格!</div>
          <div className="graduate__student">{this.props.name}</div>
          {modal}
      </div>
    );
  }
}

export default Graduate;

この部分で、実際に表示されるHTMLを作成しています。App.jsから渡されたパラメータ(props)を使って書いていきます。

constructor(props) {
    super(props);
    this.state = {isModalOpen: false};
  }

新しくstate(状態)というものが出てきましたが、propsとstateは少し異なり、stateはそのComponent内で保持されるものであって、propsみたいにComponentからComponentに渡すことはできません。詳しくはこちら
この部分でモーダルの表示・非表示を管理しています。

  handleClickLesson() {
    this.setState({isModalOpen: true});
  }

  handleClickClose() {
    this.setState({isModalOpen: false});
  }

ここで先ほどのstateを関数(handleClickLesson or handleClickClose)が呼ばれた時に更新しています。この時に注意して欲しいのが、更新する際はsetStateとしないといけないことです。

let modal;
    if (this.state.isModalOpen) {
      modal = (
        <div className='modal-area'>
        <div className='modal-inner'>
          <div className='modal-header'></div>
          <div className='modal-introduction'>
            <h2>{this.props.name}</h2>
            <p>{this.props.introduction}</p>
          </div>
          <button
            className='modal-close-btn'
            onClick={() => this.handleClickClose()}
          >
            とじる
          </button>
        </div>
      </div>
      )
    };

ここでisModalOpenがtrueの場合のみ、変数modalに値を代入し、表示させます。
そしてモーダルの中にonClick={() => this.handleClickClose()}があると思いますが、このボタンを押すことでisModalOpenがfalseになり再び非表示になります。
またApp.jsからもらってきたパラメータ(props)を{this.props.name}とすることで代入することができます。
またReactではclassをclassNameと記載します。

return (
      <div className="graduate">
          <img className="graduate__image" src={this.props.image}
               onClick={() => {this.handleClickLesson()}}
          />
          <div className="graduate__school">{this.props.school} 合格!</div>
          <div className="graduate__student">{this.props.name}</div>
          {modal}
      </div>
    );

最後ですね。
この部分で常時表示させるHTMLをApp.jsからもらったpropsを使って作成しています。
そして{modal}の部分で先ほどの変数を代入しているわけですね。imgクラスにonClickが設定されているため、画像をクリックするとisModalOpenがtrueになり、値が代入されたmodalが表示されるわけです。
では最後にHTML側でApp.jsを呼んであげましよう。

hamlの場合は

index.haml.haml
= react_component("App")

htmlの場合は

index.html.erb
<%= react_component("App") %>

これで完成です。
CSSだけ記載しておきます。
お好みで変更してください。

stylesheet.css
.performance {
    .graduate {
      padding: 30px 0;
      display: inline-block;
      width: 25%;
      text-align: center;
      &__student {
        font-size: 15px;
        padding-top: 10px;
        text-align: right;
        padding-right: 20px;
      }
      &__school {
        font-size: 20px;
        padding-top: 10px;
      }
      &__image {
        cursor: pointer;
        height: 160px;
        width: 160px;
        border-radius: 50%;
      }
      .modal-area {
        z-index: 2;
        position: fixed;
        top: 0;
        right: 0;
        bottom: 0;
        left: 0;
        background-color: rgba(0, 0, 0, 0.6);
        .modal-inner {
          position: absolute;
          top: 8%;
          right: 0;
          left: 0;
          width: 480px;
          padding-bottom: 60px;
          margin: auto;
          background-color: rgb(255, 255, 255);
          .modal-header {
            margin-bottom: 60px;
          }
          .modal-introduction p {
            color: #5876a3;
            width: 384px;
            line-height: 32px;
            text-align: left;
            margin: 36px auto 40px;
          }
          .modal-close-btn {
            font-size: 13px;
            color: #8491a5;
            width: 200px;
            padding: 16px 0;
            border: 0;
            background-color: #f0f4f9;
            cursor: pointer;
          }
          .modal-close-btn:hover {
            color: #8491a5;
            background-color: #ccd9ea;
            transition: .3s ease-in-out;
          }
        }
      }
    }
  }

番外編

Click時にモーダルが表示されるが非表示にならない場合

モーダル実装時にモーダルが閉じないというバグが起こりました。
原因を調べてみると閉じるボタンを押した際に一回閉じてから再度開いています。
これがその時のコードです。

<div className="graduate"
     onClick={() => {this.handleClickLesson()}}>
          <img className="graduate__image" src={this.props.image}/>
          <div className="graduate__school">{this.props.school} 合格!</div>
          <div className="graduate__student">{this.props.name}</div>
          {modal}
      </div>

何が問題かというと一番の親要素であるgraduateにopenmodalを設定しているせいで、その子要素であるモーダル内のclossmodalを押した際に同時に親要素のopenmodalも呼ばれてしまうからです。
なのでそれぞれのClick機能は親要素、子要素の関係に注意しましょう。

参考資料

React公式HP
Progate
https://qiita.com/k-penguin-sato/items/e3cc04f787cf3254cfae
https://qiita.com/kyrieleison/items/78b3295ff3f37969ab50

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

Reactアプリの枠組みの雛形を作ってみる

タイトルが「Reactアプリの枠組みの雛形を作ってみる」とありますが、本記事の目的は、以下のような複数のReactライブラリを一緒に使っても、それぞれのライブラリの機能がそこなわれることなく機能することを確認することです。

個人的には、antdが豊富なUIコンポネントを提供してくれているので、今後Material-UIに替えて使っていければなーと思っています。(中国発ですが、トランプ制裁とか関係ないですよね)immutable jsがリストから落ちていますが、今後の課題です。

  • @loadable/component
  • redux
  • react-redux
  • redux-logger
  • redux-thunk
  • react-router-dom
  • connected-react-router
  • antd
  • styled-components

@loadable/component
Reactのコード分割を行うライブラリ。バンドルの肥大化対応。React code splitting made easy.

redux
A predictable state container for JavaScript apps.

react-redux
Official React bindings for Redux
React Reduxの概要を理解する

redux-logger
Logger for Redux。 reduxのstateログを出力するMiddleware。

redux-thunk
Thunk middleware for Redux.
actionとして非同期関数を指定することが可能になります。

react-router-dom
react-router

connected-react-router
A Redux binding for React Router v4 and v5
history methods (push, replace, go, goBack, goForward)のdispatch が、 redux-thunk と redux-sagaの両方に互換性を持ちます。
react-router v4 と Redux

antd
Ant Design of React
ReactのUIライブラリ。豊富なUIコンポネントを比較的簡単に使える。
React UI library の antd について (1) - Button

styled-components
Visual primitives for the component age
JSでstyleを記述するCSS in JSのライブラリ。

【補足】
実は本検証の過程で、redux-formreact-router-domと一緒に使うと機能しないことが確認できました。reduxForm()connect()という2つのHOCで2重にラップした時に、propsが、最終的なコンポーネントにうまく伝わっていかない感じでした。結論としてはredux-formは使わずに、antdForm.create()を使ってvalidateを行うようにしました。私の検証ミスかもしれませんが、丸一日試行錯誤した結果です。

1.インストール

以下のコマンドで環境構築OKです。

yarn create react-app antd-test
cd antd-test
yarn add @loadable/component
yarn add redux react-redux redux-logger redux-thunk
yarn add react-router-dom connected-react-router
yarn add antd styled-components

一応package.jsonも掲載しておきます。

package.json
{
  "name": "antd-test",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "@loadable/component": "^5.10.1",
    "antd": "^3.20.2",
    "connected-react-router": "^6.5.2",
    "react": "^16.8.6",
    "react-dom": "^16.8.6",
    "react-redux": "^7.1.0",
    "react-router-dom": "^5.0.1",
    "react-scripts": "3.0.1",
    "redux": "^4.0.4",
    "redux-logger": "^3.0.6",
    "redux-thunk": "^2.3.0",
    "styled-components": "^4.3.2"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },
  "eslintConfig": {
    "extends": "react-app"
  },
  "browserslist": {
    "production": [
      ">0.2%",
      "not dead",
      "not op_mini all"
    ],
    "development": [
      "last 1 chrome version",
      "last 1 firefox version",
      "last 1 safari version"
    ]
  }
}

2.画面イメージ

本アプリのページは、ホーム画面とユーザ登録画面、ログイン画面の3つです。ページ遷移も含めて以下に紹介します。

■ページ構成:上からヘッダー、コンテンツ、フッター
image.png

■ユーザ登録:コンテンツ=ユーザ登録画面
image.png

ユーザ登録が成功すると、3秒後にログイン画面に自動遷移します。

■ログイン:コンテンツ=ログイン画面
image.png

ログインが成功すると、3秒後にホーム画面に自動遷移します。

■ホーム:コンテンツ=ホーム画面(初期画面)
image.png

3.ソースツリー

以下がソースファイルのツリーになります。
containersディレクトリが重要です。その中でもLoginとRegisterがメインです。Loadable.jsがimportポイントとなります。index.jsがreact-reduxのcontainerであり、Register.jsとLogin.jsがcomponentとなります。index.jsがReduxの処理を行い、Register.jsとLogin.jsはReduxについては何も知らないことになっています。

$ tree src
src
├── App.js
├── actions
│   └── actions.js
├── components
│   ├── Footer.js
│   ├── Header.js
│   └── Wrapper.js
├── containers
│   ├── Home
│   │   ├── Loadable.js
│   │   └── index.js
│   ├── Login
│   │   ├── Loadable.js
│   │   ├── Login.js
│   │   └── index.js
│   ├── NotFoundPage
│   │   ├── Loadable.js
│   │   └── index.js
│   ├── Register
│   │   ├── Loadable.js
│   │   ├── Register.js
│   │   ├── Register.org2
│   │   ├── _Register.new
│   │   ├── _Register.org
│   │   └── index.js
│   └── input.js
├── createStore.js
├── form-style.css
├── index.js
└── reducers
    └── index.js

4.index.js

index.jsでReduxreact-reduxconnected-react-routerの初期設定を行います。

src/index.js
import React from 'react'
import ReactDOM from 'react-dom'
import { Provider } from 'react-redux'
import { ConnectedRouter } from 'connected-react-router';
import createBrowserHistory from 'history/createBrowserHistory';
import App from './App'
import createStore from './createStore'

// connected-react-router - action経由でルーティングが可能、push,replace..
// historyを強化
const history = createBrowserHistory();
const store = createStore(history);

const dest = document.getElementById('root')

let render = () => {
  ReactDOM.hydrate(
    <Provider store={store}>
      <ConnectedRouter history={history}>
        <App />
      </ConnectedRouter>
    </Provider>,
    dest
  )
}

render()

createStore.jsはreduxのオリジナルcreateStore.jsのラッパーです。ReducersとMiddlewareの設定を行います。

src/createStore.js
import { createStore as reduxCreateStore, combineReducers, applyMiddleware } from 'redux'
import logger from 'redux-logger'
import thunk from 'redux-thunk'
import { routerMiddleware, connectRouter } from 'connected-react-router'
import * as reducers from './reducers'

// connected-react-router - action経由でルーティングが可能、push,replace..
// createStoreの再定義 - historyを引数で受け、connected-react-routerの利用を抽象化
export default function createStore(history) {
  return reduxCreateStore( // オリジナル createStore の別名
    combineReducers({
      ...reducers,
      router: connectRouter(history)
    }),
    applyMiddleware(
      logger,
      thunk,
      routerMiddleware(history)
    )
  );
}

reducersの定義です。
Redux storeは次の2つのstateを保持します。

  • state.users[]: userオブジェクトの配列
  • state.logined: ログインしているuserオブジェクト
src/reducers/index.js
export const users = (state = [], action) => {
  switch (action.type) {
    case 'ADD_USER': // *** userを追加
      return [
        ...state,    // *** 分割代入、stateに追加
        {
          email: action.user.email,
          name: action.user.name,
          password: action.user.password
        }
      ]
    default:
      return state
  }
}

export const logined = (state = {}, action) => {
  switch (action.type) {
    case 'ADD_LOGINED_USER': // *** userを追加
      return (
        {
          email: action.user.email,
          name: action.user.name,
          password: action.user.password
        })
    default:
      return state
  }
}

redux-thunkを使っているので、actionは非同期関数で定義しています。ただし非同期はsetTimeout()で模擬したものです
connected-react-routerを使っており、提供されるpush()redux-thunkと互換性があります。push()を使って、ユーザ登録成功後にログイン画面へ、ログイン成功後にホーム画面へ、自動リダイレクトしています。

src/actions/actions.js
import { push } from 'connected-react-router';

const addUser = user => ({
  type: 'ADD_USER',
  user: user
})

const addLoginedUser = user => ({
  type: 'ADD_LOGINED_USER',
  user: user
})

export const asyncAddUser = values => {
  return (dispatch, getState) => {
    setTimeout( () => {
        dispatch(addUser(values))
        dispatch(push("/login"))
    }, 3000 );
  }
}

export const asyncLogin = values => {
  return (dispatch, getState) => {
    setTimeout( () => {
      const state = getState()
      for (const user of state.users) {
        if( user.email === values.email && user.password === values.password ) {
          console.log("login succeful!!!",values)
          dispatch(addLoginedUser(user))
          dispatch(push("/"))
          return
        }
      }
      console.log("login failed!!!",values)
    }, 3000 );
  }
}

5.App.js

App.jsはこのアプリのメインになります。サイト全体のページ構成を定義します。

  • react-routerでRouteを定義します。
  • 画面の枠組みを定義して、全体のstyleを実装します

styleはstyled-componentsを利用しているほか、antd.cssとform-style.cssを読み込んでいます。

App.js
import React, { Component } from 'react'
import styled from 'styled-components'
import { Switch, Route } from 'react-router-dom'

import Home from './containers/Home/Loadable'
import LoginPage from './containers/Login/Loadable'
import RegisterPage from './containers/Register/Loadable'
import NotFoundPage from './containers/NotFoundPage/Loadable'
import Header from './components/Header'
import Footer from './components/Footer'

import 'antd/dist/antd.css';
import './form-style.css';

const AppWrapper = styled.div`
  max-width: calc(768px + 16px * 2);
  margin: 0 auto;
  display: flex;
  min-height: 100%;
  padding: 0 16px;
  flex-direction: column;
  background: papayawhip;
  .btn {
    line-height: 0;
  }
`;

class App extends Component {
  render() {
    return (
      <AppWrapper>
        <Header />
        <Switch>
          <Route exact path="/" component={Home} />
          <Route exact path="/login" component={LoginPage} />
          <Route exact path="/register" component={RegisterPage} />
          <Route path="" component={NotFoundPage} />
        </Switch>
        <Footer />
      </AppWrapper>
    );
  }
}

export default App;

form-style.cssはForm画面のstyle記述しています。ユーザ登録画面とログイン画面だけで読み込めばいいのですが、面倒なのでApp.js一か所で読み込んでいます。

src/form-style.css
.form-register-containers {
  width: 100%;
  margin: auto;
  max-width: 400px;
  padding: 50px 10px;
}

.form-register-containers .center {
  text-align: center;
}

.form-register-containers .ant-form-item-label {
  line-height: 1;
}

.form-register-containers .ant-form-item-with-help {
  margin-bottom: 0;
}

6.ユーザ登録

loadableを通して、containerコンポーネントのindex.jsを読み込みます。

src/containers/Register/Loadable.js
import loadable from '@loadable/component';
export default loadable(() => import('./index'));

index.jsはcontainerコンポーネントとして、react-reduxの設定を行い、connect()でcomponentをラップします。加えてantdForm.create()でラップしています。
2つのHOCを使っているのですが、順番に注意してください。

src/containers/Register/index.js
import { connect } from 'react-redux'
import { asyncAddUser } from '../../actions/actions'
import Register from './Register';

import { Form } from 'antd';

function mapStateToProps(state) {
  return state
}

function mapDispatchToProps(dispatch) {
  return {
    onSubmit : values => {
        dispatch(asyncAddUser(values))
    }
  }
}

let myRegister = connect(mapStateToProps, mapDispatchToProps)(Register)
myRegister = Form.create({ name: 'register_form' })(myRegister);

export default myRegister

antdのFormを使って、ユーザ登録のform画面を定義しています。Form.create()でラッピングしているので、様々なvalidate関数を利用できます。

src/
import React from 'react';
import { Form, Input, Icon, Button } from 'antd';

class Register extends React.Component {

  state = {
    confirmDirty: false,
    autoCompleteResult: [],
  };

  componentDidMount() {
    // To disabled submit button at the beginning.
    this.props.form.validateFields();
  }

  handleSubmit = e => {
    console.log(this.props)
    e.preventDefault();
    this.props.form.validateFields((err, values) => {
      if (!err) {
        console.log('Submit OK: ', values);
        this.props.onSubmit( values )
      } else {
        console.log('Submit NG: ', values);
      }
    })
  }

  handleConfirmBlur = e => {
    const { value } = e.target;
    this.setState({ confirmDirty: this.state.confirmDirty || !!value });
  };

  compareToFirstPassword = (rule, value, callback) => {
    const { form } = this.props;
    if (value && value !== form.getFieldValue('password')) {
      callback('Two passwords that you enter is inconsistent!');
    } else {
      callback();
    }
  };

  validateToNextPassword = (rule, value, callback) => {
    const { form } = this.props;
    if (value && this.state.confirmDirty) {
      form.validateFields(['confirm'], { force: true });
    }
    callback();
  };

  render() {
    const { getFieldDecorator, getFieldsError, getFieldError, isFieldTouched } = this.props.form;

    // Only show error after a field is touched.
    const emailError = isFieldTouched('email') && getFieldError('email');
    const nameError = isFieldTouched('name') && getFieldError('name');
    const passwordError = isFieldTouched('password') && getFieldError('password');
    const confirmError = isFieldTouched('confirm') && getFieldError('confirm');
    const buttonDisable = getFieldError('email') || getFieldError('name') || getFieldError('password') || getFieldError('confirm')

    return(
      <Form onSubmit={this.handleSubmit} className="form-register-containers">
        <h1 className="center">
          ユーザ登録
        </h1>

        <Form.Item label="メールアドレス" validateStatus={emailError ? 'error' : ''} help={emailError || ''}>
          {getFieldDecorator('email', {
            rules: [ {type: 'email', message: 'The input is not valid E-mail!',},
                     { required: true, message: 'Please input your email!' }],
            })(
              <Input
                prefix={<Icon type="mail" style={{ color: 'rgba(0,0,0,.25)' }} />}
                placeholder="email"
              />,
          )}
        </Form.Item>

        <Form.Item label="パスワード" validateStatus={passwordError ? 'error' : ''} help={passwordError || ''}>
          {getFieldDecorator('password', {
            rules: [ { required: true, message: 'Please input your password!' },
                     { validator: this.validateToNextPassword,} ],
            })(
              <Input
                prefix={<Icon type="lock" style={{ color: 'rgba(0,0,0,.25)' }} />}
                placeholder="password"
              />,
          )}
        </Form.Item>

        <Form.Item label="確認パスワード" validateStatus={confirmError ? 'error' : ''} help={confirmError || ''}>
          {getFieldDecorator('confirm', {
            rules: [ { required: true, message: 'Please input your confirmPassword!' },
                     { validator: this.compareToFirstPassword,} ],
            })(
              <Input
                prefix={<Icon type="lock" style={{ color: 'rgba(0,0,0,.25)' }} />}
                placeholder="confirmPassword"
                onBlur={this.handleConfirmBlur}
              />,
          )}
        </Form.Item>

        <Form.Item label="名前" validateStatus={nameError ? 'error' : ''} help={nameError || ''}>
          {getFieldDecorator('name', {
            rules: [{ required: true, message: 'Please input your name!' }],
            })(
              <Input
                prefix={<Icon type="user" style={{ color: 'rgba(0,0,0,.25)' }} />}
                placeholder="name"
              />,
          )}
        </Form.Item>

        <Form.Item className="center">
          <Button
            type="primary"
            htmlType="submit"
            className="btn-submit"
            disabled = {buttonDisable}
          >
            ユーザ登録
          </Button>
        </Form.Item>
      </Form>
    )
  }
}

export default Register

7.ログイン

ログイン画面はユーザ登録画面と構成が全く同じです。説明も重複になるので、省略します。

src/containers/Login/Loadable.js
import loadable from '@loadable/component';
export default loadable(() => import('./index'));
src/containers/Login/index.js
import { connect } from 'react-redux'
import { asyncLogin } from '../../actions/actions'
import Login from './Login';

import { Form } from 'antd';

function mapStateToProps(state) {
  return state
}


function mapDispatchToProps(dispatch) {
  return {
    onSubmit : values => {
        dispatch(asyncLogin(values))
    }
  }
}

let myLogin = connect(mapStateToProps, mapDispatchToProps)(Login)
myLogin = Form.create({ name: 'login_form' })(myLogin);

export default myLogin
src/containers/Login/Login.js
import React from 'react';
import { Form, Input, Icon, Button } from 'antd';

class Login extends React.Component {

  componentDidMount() {
    // To disabled submit button at the beginning.
    this.props.form.validateFields();
  }

  handleSubmit = e => {
    console.log(this.props)
    e.preventDefault();
    this.props.form.validateFields((err, values) => {
      if (!err) {
        console.log('Received values of form: ', values);
        this.props.onSubmit( values )
      }
    })
  }

  render() {
    const { getFieldDecorator, getFieldsError, getFieldError, isFieldTouched } = this.props.form;

    // Only show error after a field is touched.
    const emailError = isFieldTouched('email') && getFieldError('email');
    const passwordError = isFieldTouched('password') && getFieldError('password');
    const buttonDisable = getFieldError('email') || getFieldError('password')

    return(
      <Form onSubmit={this.handleSubmit} className="form-register-containers">
        <h1 className="center">
          ログイン
        </h1>
        <Form.Item label="メールアドレス" validateStatus={emailError ? 'error' : ''} help={emailError || ''}>
          {getFieldDecorator('email', {
            rules: [ {type: 'email', message: 'The input is not valid E-mail!',},
                     { required: true, message: 'Please input your email!' }],
            })(
              <Input
                prefix={<Icon type="mail" style={{ color: 'rgba(0,0,0,.25)' }} />}
                placeholder="email"
              />,
          )}
        </Form.Item>

        <Form.Item label="パスワード" validateStatus={passwordError ? 'error' : ''} help={passwordError || ''}>
          {getFieldDecorator('password', {
            rules: [{ required: true, message: 'Please input your password!' }],
            })(
              <Input
                prefix={<Icon type="lock" style={{ color: 'rgba(0,0,0,.25)' }} />}
                placeholder="password"
              />,
          )}
        </Form.Item>


        <Form.Item className="center">
          <Button
            type="primary"
            htmlType="submit"
            className="btn-submit"
            disabled = {buttonDisable}
          >
            ログイン
          </Button>
        </Form.Item>
      </Form>
    )
  }
}

export default Login

8.ホーム

ホーム画面は特にありません。

src/containers/Home/Loadable.js
import loadable from "@loadable/component";
export default loadable(() => import("./index"));
src/
import React from 'react';
import { Route, Link } from 'react-router-dom';

const Home = () => (
  <div>
    <h1>私のホームページへようこそ !!!</h1>
    <ul>
      <li><Link to="/login">Login</Link></li>
      <li><Link to="/register">Register</Link></li>
    </ul>
  </div>
);

export default Home;

URLで指定されたページが見つからい場合は、以下のcomponentが表示されます。

src/containers/NotFoundPage/Loadable.js
import loadable from "@loadable/component";
export default loadable(() => import("./index"));
src/containers/NotFoundPage/index.js
import React from "react";

export default class NotFound extends React.PureComponent {
  render() {
    return <h1>This is the NotFoundPage Page!</h1>;
  }
}

9.ヘッダー/フッター

ヘッダーとフッターですが、特に説明は不要でしょう。

src/components/Header.js
import React from 'react';
import { Link } from "react-router-dom";
import { Breadcrumb } from 'antd';

function Header() {
  return (
    <Breadcrumb>
      <Breadcrumb.Item>
        <Link to="/">ホーム</Link>
      </Breadcrumb.Item>
      <Breadcrumb.Item>
        <Link to="/login">ログイン</Link>
      </Breadcrumb.Item>
      <Breadcrumb.Item>
        <Link to="/register">ユーザ登録</Link>
      </Breadcrumb.Item>
    </Breadcrumb>
  );
}

export default Header;
src/
import React from 'react';
import Wrapper from './Wrapper';

function Footer() {
  return (
    <Wrapper>
      <section>Footer footer Footer!!!</section>
    </Wrapper>
  );
}

export default Footer;
src/components/Wrapper.js
import styled from 'styled-components';

const Wrapper = styled.footer`
  display: flex;
  justify-content: space-between;
  padding: 3em 0;
  border-top: 1px solid #666;
`;

export default Wrapper;

今回は以上です。

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

【Firebase Auth】1つのアカウントにメールアドレスと電話番号を紐付ける

FirebaseのAuthenticationを利用したプロダクトの開発を行う機会があり、一つのアカウントにメールアドレスと電話番号を紐付ける必要があったのでその手順を残しておきます。
とても簡単に実装できました。

参考: https://firebase.google.com/docs/auth/web/phone-auth?hl=ja

以下のようになっていればOKです。
スクリーンショット 2019-07-13 16.34.45.png

今回の手順をgitで公開しています。こちらから確認できます。

スクリーンショット 2019-07-14 15.08.18.png

googleでログインさせた後に電話番号で認証させるといったことも簡単に実現できそうですね。

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

「SPAらしさ」について考える(メモ)

ここ最近Reactを使ったSPAの開発に勤しんでいたところ

やればやるほどSPAってなんなんだとなった。
単一ページ?いやでも複数ページあるけど。
ゲシュタルト崩壊を起こした。

エスピーエー ワカラナイ ググル
ググッタ メイカク テイギ ナイ

SPAというのはなんとなく定義されているものの明確な定義はないらしい。

というわけでどういうものが「SPA」だと感じるのか
ひいては「SPAらしさ」とはなにかについて考察してみた。

結論

あらかじめ結論を書いてしまうと
この記事を書いた結果以下の結論に行き着いた(あくまで個人の見解)

SPAらしさ=ネイティブっぽいかどうか

以下はSPAらしさってなんだとか
なんかもういろいろごちゃごちゃ語っています。

SSRはどうか

SSRってそもそもサーバーでHTML作ってるし
普通に通信発生してるし

バックエンドとフロントエンドのソースコードを共通化できる
アイソモーフィックではあるものの
SPAらしくない。

結局通信してURLに対し異なるHTMLを返すというのは
先祖返りというか、それSPAで作る意味あったの。。。

という疑惑を抱いてしまうので「SPAらしくない」と判断。

(でも検索ロボットに対してのみSSRするならSPAらしいと思う)

SPAの利点?

通常のWebページではできないUXができる
高速なページ遷移
読み込みが早い

など、色々言われてはいるものの
正直そこまでメリットと言えるレベルか?という思いもあり
SPA自体にはさほどメリットはないと思っている。

SPAは副産物的なものでは?

SPAがトレンドっぽくなっているのは
SPAにメリットがあるのではなく

バックエンド側をシンプルなAPIの実装のみにすることにメリットがあった
と言う方が正しいと思っている。

JSONを返すだけのAPIは
ネイティブからもウェブからも利用できる

じゃぁ次はネイティブもフロントも同じコードで書けたらいいよね
とアイソモーフィックを目指した結果のSPA

と考えた方がしっくりくる。

SPAは早い?

SPAは読み込みが早いと言われることもあるが
JSが肥大化して実はそんな早くないということも珍しくないと思うし

パフォーマンスチューニングによって
最終的に従来のウェブサイトより早くできるとは思うものの

それがすごいメリットと言えるほどかというと微妙なラインでは?

jQueryでは限界がある?

よく複雑なDOM操作をjQueryでやるのは大変と説明されることもあるが

DOM操作だけで考えた場合、jQueryの方が優れていると感じる面もある。
従来のウェブサイトらしいサイトであればjQueryのがはるかに簡単だし
十分なケースも多いと思われる。

ただDOM生成やネイティブアプリレベルのDOM操作
という観点で考えた場合はやはりReactの方が優れていると感じる。
(あとVDOMによる差分更新もメリットか)

ネストされたDOM構造をjQueryで頑張って作っていくというのは
不可能ではないがあまり書きたいとは思えない。(好みの問題)

Reactを使う理由

個人的になぜReactを使うかという事を改めて考えた場合

①バックエンドはAPIだけにしたい
②DOM生成をjQueryでやるのはつらい
③VDOMによる差分レンダリングだから無駄が少ない

という3点からReactを採用しているようだ。
(なんとなく利点は感じていたものの、改めて自覚した)

将来的にネイティブにも横展開できるかどうかという点はさほど考えていない。
(簡単にできるなら嬉しいなー程度)

個人的な悩みの解決

従来のWebサイトのようなSPAっぽくないサイトを
Reactで作る意味はあるのかというもやもやがあったのだが

今持てる知識の中で
バックエンドはAPIのみにしたいとなった場合

フロントは以下の2択を迫られる

①静的なhtmlでサイトを作るか
②SPAで作るか

SPAで作るメリットあるのかと思っていたが
バックエンドをAPIだけにできるじゃんと考えるとスッキリできた。

主となるのがフロントなのかバックエンドなのか

これは個人的な感覚が強い部分だが
APIからデータを取得する頻度は少ない方がSPAらしく感じる。

ゲームなんかでは、基本的にプレイ中は常にオンメモリでデータが管理され
セーブ時に必要なデータをまとめてHDD等に保存する

この作りに近いほどSPAらしさを感じる。

SPAらしくないと感じる例としては
何かしらデータを更新するたびに最新のデータをAPIから取得しなおして
画面を全更新かけるような作りである。

基本的にフロント側でデータを管理し
バックエンドはデータの保管場所程度
というくらいがSPAらしいのではないかと感じる。

まとめ

上記を踏まえたまとめ

SPAのメリット

①バックエンドをAPIだけにできる
②ネイティブの作りっぽくなるほど恩恵が得られやすい

Reactのメリット

①DOM生成楽
②ある程度規模が大きくなっても管理しやすい(実力次第でもある)

SPAらしさ

ネイティブに近いほどSPAらしさが出る

SPAらしさを損なう要因

  • SSR
  • APIに頼りすぎるDOM更新

あとがき

五月雨に書きなぐった事で
今までもやもやしていた事が自分なりに整理されました。

そしてSPAは難しいと言う事も再確認できました。

SPAを開発するとなった場合、多くはReactかVueを使うことになると思いますが

そもそもjQueryだけでもそこそこ複雑なアプリケーションを作る事は可能です。

にも関わらずReactやVueという新しいフレームワークが誕生したのは
jQueryで作るのが難しいくらい複雑なものを作ろうとしているからだと思いました。

いくらReactを使えばSPAが作れるといったところで
そもそもSPAが難しいジャンルであり
(特にReactはあくまでViewライブラリ、Store管理は別途知識が必要ですし)

それなりの技術力、設計力、経験値がなければ
ReactやVueを使ったところでカオスになってしまう。

でも、だからこそ挑戦しがいがあり、面白く感じる自分がいる
そうも思いました。

終わり。

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

mobx-react-lite入門 前編: mobx-react-liteのObserver

0.はじめに

JavaScriptの「シンプルかつスケーラブルな」状態管理ライブラリことMobXReactと結びつけて、楽しくWebアプリケーションを作れるようになってみたいと思いませんか?

当記事ではReactとMobXを組み合わせて使うためのライブラリmobx-react-liteを使って、観測可能な状態観測者による状態管理を俯瞰してみたいと思います。

前編では、mobx-react-liteで提供されるObserverを紹介します。

筆者の環境
  • OS: macOS Mojave 10.14.5
  • ブラウザ: Safari バージョン12.1.1
  • Node.js: v10.16.0
  • Yarn: 1.15.2

環境と想定読者

node.jsのインストールが必要です。node.jsを使うならば、備え付けのパッケージマネージャのnpmについて詳しく知る必要があります。しかし、当記事ではパッケージマネージャとしてYarnを用います。yarnは以下からダウンロードできます。

MobXの前に、軽くReactに関する知識が必要です。

  • Hello World|React を確認しておきましょう。JSXを知り、関数型コンポーネントが書けるようになればオッケーです。
  • 今回のチュートリアルでは、関数型コンポーネントとHooksをたくさん書くので、以下の大変参考になるQiita記事を目を通しておくかもいいかもしれません。なお、本チュートリアルにおいてはHooksは、都度説明を入れるつもりです。React 16.8: 正式版となったReact Hooksを今さら総ざらいする|Qiita by uhyo

1.準備

Next.js 9を使って、今回のチュートリアルの環境を作っていきましょう。

パッケージマネージャと依存関係のインストール

作業用ディレクトリを作成し、そこで以下のコマンドを実行することで依存関係(パッケージ)をインストールします。

terminal
yarn add next@latest react@latest react-dom@latest mobx mobx-react-lite

執筆当時のpackage.json
package.json
{
  "dependencies": {
    "mobx": "^5.11.0",
    "mobx-react-lite": "^1.4.1",
    "next": "^9.0.1",
    "react": "^16.8.6",
    "react-dom": "^16.8.6"
  }
}

開発サーバの立ち上げ

pagesという名前のディレクトリを作成し、その中にindex.jsxというファイルを作成します。

pages/index.jsx
const Index = () => <p>It Works!</p>;

export default Index;

そして、以下のコマンドを実行すると開発サーバが立ち上がります。

terminal
yarn next

そして、http://localhost:3000 にアクセスした時に以下のように表示されていたら成功です。

スクリーンショット 2019-07-13 3.11.53.png

開発サーバーはターミナルでctr+cでを打てば終了します。

Next.jsの基本

Next.jspages/配下の.jsxファイルなどでReactのコンポーネントをexport defaultすると、そのディレクトリ名に対応するページが新規作成されます。

また、開発サーバーにおけるNext.jsはファイルの更新を検出し、サーバーを再起動することなく更新を反映させます。

次のチュートリアルの準備のために、pages/counter.jsxを作成して中をこのようにします。

pages/counter.jsx
const CounterPage = () => {
    return (
        <div>
            <p>ここはカウンターページです</p>
            <hr/>
        </div>
    );
};

export default CounterPage;

すると先ほどの説明のように、counterというページがブラウザで読み込めるようになります。
http://localhost:3000/counter
スクリーンショット 2019-07-13 3.24.13.png

2.Hello MobX

MobXの観測可能な状態観測者を早速使ってみましょう。

pages/counter.jsx
import {Observer, useLocalStore} from "mobx-react-lite"; //追加

const CounterPage = () => {
    /***
     * 観測可能な状態
     * ストア:{counter: number}のように扱える
     */
    const store = useLocalStore(() => ({counter: 0}));

    /***
     * ストアの操作のための関数
     * ボタンに与える
     */
    function increment() {
        store.counter++;
    }

    function decrement() {
        store.counter--;
    }

    return (
        <div>
            <p>ここはカウンターページです</p>
            <hr/>
            {/***
             * 観測者コンポーネント
             * 観測可能な状態の変化に応じて更新される
             */}
            <Observer>{() => (<p>{store.counter}</p>)}</Observer>

            <button onClick={increment}>+</button>
            <button onClick={decrement}>-</button>
        </div>
    );
};

export default CounterPage;

http://localhost:3000/counter
スクリーンショット 2019-07-13 3.53.44.png

これで、+を押せばカウントアップされ、-を押せばカウントダウンするページが作れました。

解説

観測可能な状態

mobx-react-liteではuseLocalStore Hookを使えば、観測可能な状態を作ることができます。観測可能な状態の作り方は他にもあります。

useLocalStore(() => {return オブジェクト}); // これでオブジェクト型のObservableステートが作れる

useLocalStoreはHookですので、二つのルールがあります。

フックは JavaScript の関数ですが、2 つの追加のルールがあります。

  • フックは関数のトップレベルのみで呼び出してください。ループや条件分岐やネストした関数の中でフックを呼び出さないでください。
  • フックは React の関数コンポーネントの内部のみで呼び出してください。通常の JavaScript 関数内では呼び出さないでください(ただしフックを呼び出していい場所がもう 1 カ所だけあります — 自分のカスタムフックの中です。これについてはすぐ後で学びます)。

フック早わかり|React

したがって、クラス型のコンポーネントからはuseLocalStore Hookは使えません。

観測者

mobx-react-liteでは観測者Reactコンポーネントを作ることができます。Observerコンポーネントは、子要素のようにReactのコンポーネントを書くことができず、render関数を渡してやる必要があります。つまりObserverコンポーネントを使う際は以下の形式になることが多いでしょう。

<Observer>{() => (観測可能な状態にアクセスするReact要素)}</Observer>

観測可能な状態へのアクセスとは、短絡的な話だと観測可能な状態.観測可能な状態のメンバーにおける.を含むということです。

3. mobx-react-liteで提供される観測者(HOC, Observerコンポーネント, useObserver)

ちょっと準備:Hooksやコンポーネントを使い回せるようにする

先ほどのチュートリアルでuseLocalStoreによるカウンターのストアを作りました。これを使い回せるようにカスタムHookを作成しましょう。

hooks/counterStore.js
import { useLocalStore } from "mobx-react-lite";

export function useCounterStore() {
  const store = useLocalStore(() => ({
    counter: 0,
    increment: () => {
      store.counter++;
    },
    decrement: () => {
      store.counter--;
    }
  }));
  return store;
}

incrementや、decrementをStoreの中に入れてしまうことで、取り回しが良くなります。

次にボタンもコンポーネントにしましょう。

components/counterButton.jsx
export const CounterButton = props => (
  <>
    <button onClick={props.store.increment}>+</button>
    <button onClick={props.store.decrement}>-</button>
  </>
);

ここまででファイル構成はこのようになっています。

.
├── components
│   └── counterButton.jsx
├── hooks
│   └── useCounterStore.js
├── package.json
├── pages
│   ├── counter.jsx
│   └── index.jsx
└── yarn.lock

Observerコンポーネント

Observerコンポーネントは最もよく使う観測者でしょう。使い方はすでに見た通りです。

pages/observer.jsxの全体
pages/observer.jsx
import { Observer } from "mobx-react-lite";

import { useCounterStore } from "../hooks/useCounterStore";
import { CounterButton } from "../components/counterButton";

const Counter = props => <p>{props.counter}</p>;

const CounterPage = () => {
  const store = useCounterStore();

  return (
    <div>
      <p>オブザーバーコンポーネントの例</p>
      <hr />

      <Observer>{() => <Counter counter={store.counter} />}</Observer>
      <CounterButton store={store} />
    </div>
  );
};

export default CounterPage;

Observerコンポーネントでハマるとすればrender propsです。

Observerコンポーネントが動かない例

Observerコンポーネントは、直下のrender関数の更新をすることができますが、render関数中でさらにrender関数を呼び出された場合、子のrender関数の更新をすることができません。したがって以下のような例ではカウンターが動きません。

動かない例

<Observer>
  {() => (
    <Ueshita
      render={props => (
        <>
          ここは{props.name}
          <Counter counter={store.counter} />
        </>
      )}
    >
      {props => (
        <>
          ここは{props.name}
          <Counter counter={store.counter} />
        </>
      )}
    </Ueshita>
  )}
</Observer>

動く例

<Ueshita
  render={props => (
    <>
      ここは{props.name}
      <Observer>{() => <Counter counter={store.counter} />}</Observer>
    </>
  )}
>
  {props => (
    <>
      ここは{props.name}
      <Observer>{() => <Counter counter={store.counter} />}</Observer>
    </>
  )}
</Ueshita>

pages/observer2.jsxの全体
pages/observer2.jsx
import { Observer } from "mobx-react-lite";

import { useCounterStore } from "../hooks/useCounterStore";
import { CounterButton } from "../components/counterButton";

const Counter = props => <p>{props.counter}</p>;

const Ueshita = props => (
  <div>
    {props.render({ name: "上" })}
    <hr />
    {props.children({ name: "下" })}
  </div>
);
const CounterPage = () => {
  const store = useCounterStore();

  return (
    <div>
      <p>オブザーバーHOCの例</p>
      <hr />
      <h2>動かない</h2>
      <Observer>
        {() => (
          <Ueshita
            render={props => (
              <>
                ここは{props.name}
                <Counter counter={store.counter} />
              </>
            )}
          >
            {props => (
              <>
                ここは{props.name}
                <Counter counter={store.counter} />
              </>
            )}
          </Ueshita>
        )}
      </Observer>
      <hr />
      <h2>動く</h2>
      <Ueshita
        render={props => (
          <>
            ここは{props.name}
            <Observer>{() => <Counter counter={store.counter} />}</Observer>
          </>
        )}
      >
        {props => (
          <>
            ここは{props.name}
            <Observer>{() => <Counter counter={store.counter} />}</Observer>
          </>
        )}
      </Ueshita>
      <CounterButton store={store} />
    </div>
  );
};

export default CounterPage;

スクリーンショット 2019-07-13 21.23.56.png

参考: https://mobx-react.netlify.com/observer-component

observer HOC

HOCはコンポーネントを引数として、コンポーネントを返す関数です。mobx-react-liteには、ただのコンポーネントを受け取って、それを観測者にするobserverというHOCがあります。先ほど出てきたObserverは先頭が大文字です。注意してください。

pages/hoc.jsx
import { observer } from "mobx-react-lite";

import { useCounterStore } from "../hooks/useCounterStore";
import { CounterButton } from "../components/counterButton";

const Counter = props => <p>{props.store.counter}</p>;

const HOCCounter = observer(Counter);

const CounterPage = () => {
  const store = useCounterStore();

  return (
    <div>
      <p>オブザーバーHOCの例</p>
      <HOCCounter store={store}/>
      <hr />
      <CounterButton store={store} />
    </div>
  );
};

export default CounterPage;

http://localhost:3000/hoc

スクリーンショット 2019-07-13 19.23.49.png

これは、先ほどの例と同じようにカウンターとして機能します。

落とし穴: observerが機能しない

ちょっと待ってください。 この例のCounterコンポーネントのpropsの取り方が少し冗長なように見えます。この機能を実装するならばいちいちstoreを渡さなくても良さそうに思えますね。つまりこちらの方が汎用性が高いコンポーネントでしょう。

const CounterMod = props => <p>{props.counter}</p>;

これをobserver HOCに繋ぎます。

const HOCCounterMod = observer(CounterMod);

そして表示してみましょう。比較用に、Observerコンポーネントに直繋ぎする例も見てみます。

<>
カウンターModを直にオブサーバーにつなぐ例
<Observer>{() => <CounterMod counter={store.counter} />}</Observer>
単にHOCで繋いだ例
<HOCCounterMod counter={store.counter}/>
</>

pages/hoc.jsxの全体
pages/hoc.jsx
import { observer, Observer } from "mobx-react-lite";

import { useCounterStore } from "../hooks/useCounterStore";
import { CounterButton } from "../components/counterButton";

const Counter = props => <p>{props.store.counter}</p>;

const HOCCounter = observer(Counter);

const CounterMod = props => <p>{props.counter}</p>;

const HOCCounterMod = observer(CounterMod);

const CounterPage = () => {
  const store = useCounterStore();

  return (
    <div>
      <p>オブザーバーHOCの例</p>
      <HOCCounter store={store} />
      <hr />
      カウンターModを直にオブサーバーにつなぐ例
      <Observer>{() => <CounterMod counter={store.counter} />}</Observer>
      単にHOCで繋いだ例
      <HOCCounterMod counter={store.counter}/>
      <CounterButton store={store} />
    </div>
  );
};

export default CounterPage;

スクリーンショット 2019-07-13 19.31.06.png

なんと、動かない例が出てしまいました!単にHOCに繋いだものが更新されないのです。

これを動く例にしてみましょう。以下の二つを追加してみます

const HOCCounterModFixed = observer(props => <CounterMod counter={props.store.counter}/>);
<>
StoreからアクセスするようにHOCで繋いだ例
<HOCCounterModFixed store={store}/>
</>

pages/hoc.jsxの全体
pages/hoc.jsx
import { observer, Observer } from "mobx-react-lite";

import { useCounterStore } from "../hooks/useCounterStore";
import { CounterButton } from "../components/counterButton";

const Counter = props => <p>{props.store.counter}</p>;

const HOCCounter = observer(Counter);

const CounterMod = props => <p>{props.counter}</p>;

const HOCCounterMod = observer(CounterMod);

const HOCCounterModFixed = observer(props => <CounterMod counter={props.store.counter}/>);


const CounterPage = () => {
  const store = useCounterStore();

  return (
    <div>
      <p>オブザーバーHOCの例</p>
      <HOCCounter store={store} />
      <hr />
      カウンターModを直にオブサーバーにつなぐ例
      <Observer>{() => <CounterMod counter={store.counter} />}</Observer>
      単にHOCで繋いだ例
      <HOCCounterMod counter={store.counter}/>
      StoreからアクセするようにHOCで繋いだ例
      <HOCCounterModFixed store={store}/>
      <CounterButton store={store} />
    </div>
  );
};

export default CounterPage;

スクリーンショット 2019-07-13 19.35.52.png

これで予想通りに動くようになりました。

落とし穴の理由

MobXにおいて観測者が追跡しているものは観測可能な状態へのアクセスであり、シンプルな言い方をすれば観測可能な状態.観測可能な状態のメンバーにおける.を追っているのです。

const CounterMod = props => <p>{props.counter}</p>;
const HOCCounterMod = observer(CounterMod);

つまり

const HOCCounterMod = observer(props => <p>{props.counter}</p>);

<HOCCounterMod counter={store.counter}/>

と使ったところで、observerの引数の中でstore.counter.が見えていません。

MobXは観測可能な状態のメンバーそのもの、つまり値そのものの変化に対して反応することはできません。

したがってobserver HOCにおいては、propsで観測可能な状態を渡すようにしましょう。観測可能な状態のメンバーをコピーしたものや、観測可能な状態にアクセスした後の値を渡しても、表示は更新されません。

つまりHOCオブジェクトにおいてはJSXの要素の中で.があってもダメなのです。

<HOCCounterMod counter={store.counter}/>

この仕様のため、慣れてくると多くの場合でObserverコンポーネントを使うことが一番都合が良いと思うようになってきます。

つまり、以下の内容はちゃんと値の変化に応じて描画されます。

<Observer>{() => <HOCCounterMod counter={store.counter}/> />}</Observer>

こんなことをするのならば、observer HOCを使った意味がありませんね。

とはいえ、observer HOCを使えばこのようなことができます。

pages/hoc2.jsx
import { observer } from "mobx-react-lite";

import { useCounterStore } from "../hooks/useCounterStore";
import { CounterButton } from "../components/counterButton";

const Counter = props => <p>{props.counter}</p>;

const CounterPage = observer(() => {
  const store = useCounterStore();

  return (
    <div>
      <p>オブザーバーHOCの例2</p>
      <hr />

      <Counter counter={store.counter} />

      <CounterButton store={store} />
    </div>
  );
});

export default CounterPage;

これはちゃんと動きます。しかし再描画の範囲がCounterだけではなく、CounterPage全体ということに注意をしてください。

やたらobserver HOCをディスリましたが、使い用はあるはずです。

でも、もう一つディスりポイントがあって、observer HOCはReactのLegacy Contextに依存しています。その点でもobserver HOCは気味が悪いですね。

参考: https://mobx-react.netlify.com/observer-hoc

useObserver Hook

useObserver Hookは前述の二つの観測者で内部的に利用されているReact Hookです。observer HOCの代わりのような使い方ができます。

実際に現在のObserverコンポーネントの実装(TypeScript)は以下のようになっています。

function ObserverComponent({ children, render }: IObserverProps) {
    const component = children || render
    if (typeof component !== "function") {
        return null
    }
    return useObserver(component)
}

useObserverはmobx-react-liteの心臓部と言って差し支えないでしょう。
参考: https://github.com/mobxjs/mobx-react-lite/blob/master/src/ObserverComponent.ts

useObserverobserver HOCのように使いたければ以下のようにしましょう。

pages/useobserver.jsx
import { useObserver } from "mobx-react-lite";

import { useCounterStore } from "../hooks/useCounterStore";
import { CounterButton } from "../components/counterButton";

const Counter = props => <p>{props.counter}</p>;

const HookCounter = props => {
  //普通はこれをそのままreturnでオッケー:Hookっぽさを出すためにこうした。
  const Component = useObserver(() => (
    <Counter counter={props.store.counter} />
  ));
  return Component;
};

const CounterPage = () => {
  const store = useCounterStore();

  return (
    <div>
      <h2>オブザーバーHOOKの例</h2>
      <HookCounter store={store} />
      <hr />
      <CounterButton store={store} />
    </div>
  );
};

export default CounterPage;

まとめ

  • 基本はObserverコンポーネントを利用しましょう。
  • 観測者が動かない時は、観測可能な状態からのアクセスを観測できているかチェックする。
    • Render Propsで動かない時は、Observerをrender関数の中に入れる
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む