20200531のReactに関する記事は12件です。

React UI

下記を利用し作ってみました。

  • React
  • react-hook-from
  • TypeScript
  • material-ui
  • react-bootstrap
  • GraphQl

contextやreducerを使用した状態管理。そしてmateria-uiとreact-bootstrapを使用しUIを構築し、Dialogや情報入力蘭を作成しました。
ある程度使い慣れてはいるのですが、改めて学び直しもかねて作成したのですが、大変な面もありますがJavaScriptやTypeScriptが好きな方にとっては楽しくかけたりできるのでいいなと思いました。

その他、GitHub GraphQL APIを使用し簡単な検索機能も作成いたしました。
なかなか安全感を感じるなと思いました。

今回は、主にReact Hooksやアクセシビリティについても大変考慮されているReactなのでそういった内容を書いて行こうと思います。

毎回、状態管理はどういった設計しようかとかUIはどんな操作感や見た目にしようかなど考えます。
なので、思ったより時間がかかりましたが、それなりの物ができたように思います。

最後、静的サイトホスティングサービスのNetlifyを使用いたしました。このことについては特に書くことがありませんので、開発に使用したリストに乗っていますが載せているだけになります。

また、TypeScriptも使用していますので、このことについても触れて行こうと思います。

FLUX

結合関係を複雑にせず、更新の流れを一方通行にし依存関係をシンプルにする。コンポーネントが状態を持つ必要性を最小減にする概念
--- Flow ---
Action → Dispatcher → Store → View → Action → Dispatcher → Store → ...
この繰り返し

useReducer

reducerとは、変化させるものや適用させる関数のことを指します。
本来は、別ファイルに分けるのが一般的かと思いますが、
みやすくするためにまとめて記載しております。

  interface State {
    counter: number
  }

  const initState: State = {
    counter: 0
  }
  const addCounter = (n: number) => ({ type: "COUNTER_ADD", payload: n } as const)
  const subCounter = (n: number) => ({ type: "COUNTER_SUB", payload: n } as const)

  type ReturnType<T extends (...arg: any[]) => any> = T extends (
    ...arg: any[]
  ) => infer R ? R : never;
  type Action = ReturnType<typeof addCounter | typeof subCounter>

  const reducer: React.Reducer<State, Action> = (state, action) => {
    switch (action.type) {
      case "COUNTER_ADD":
        return { counter: state.counter + action.payload }
      case "COUNTER_SUB":
          return { counter: state.counter - action.payload }
      default:
          return state
      }
  }

  const [reduserState, dispatch] = useReducer(reducer, initState)

    return (
    <div>
      <span>{reduserState.counter} : reduserState</span>
      <p onClick={ () => dispatch(addCounter(1))}>ADD</p>
      <p onClick={ () => dispatch(subCounter(1))}>SUB</p>
    </div>
  )

1つ目の引数には、Reduxのreducerとほぼ同じ仕様の関数を指定し、
2つ目の引数には、初期値を指定。

  • 返り値はuseState同様にタプルとなっていて1つ目の要素はステート変数で、
  • 2つ目の要素では、Actionを指定するためのDispatch関数になります。

useContext createContext

useContext createContext共にまとめて一枚のファイルに記載されることはおそらく管理の面でもあまり無いかと思います。
こちらもuseReducer同様みやすくするためにまとめて記載しております。

const context = createContext("");
const ConsumerSample = () => {
  const Notifications = useContext(context)
  //console.log("useContext "+Notifications);
  return (
    <div>
      <div>{Notifications}</div>
    </div>
  )
};

return (
  <div>
    <context.Provider value={"useContextによって返却された要素を表示"}>
      <ConsumerSample />
    </context.Provider>
  </div>
)

状態管理をする際、ContextやuseReducerそしてReduxと選択肢があるかと思います。
選ぶ際のポイントはサービス規模や拡張していくことが予めわかっていればReduxの方がいいかもしてません。
なぜなら、ReactのルールからReduxを使用する少々複雑になっていくことが多いからです。
今回僕は、Contextを使用した状態管理を行いました。非常にわかりやすく管理しやすいと感じております。
ですが、Providerに持たせるValueが多くなるにつれて長ったらしくなっているよな気がして来てしまいました。

十分に状態管理の手法を考え、選ばれることをおすすめいたします。

そして、複雑なってしまいかねないので、シンプルに設計することも大事なことかと思います。

useContext

Reactコンポーネントで、階層の離れたコンポーネントに値を渡すHooks関数
読み取り専用です。

createContext

Contextは、createContextによって生成。ProviderとConsumerがあります。
Providerで、value={}で引数を渡し、
ConsumerでProviderでvalueを受け取る

useState

  const Field = useCallback((e) => {
    setValue({
      ...value,
      [e.target.name]: e.target.value,
    })
  }, [value, setValue])

言わずもがなだと思いますので、紹介のみにいたします。

以上になります。

他は次回アウトプットしていきます。

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

Next.js に入門してみた

Next.js (現バージョン 9.4) に入門します。

何をするものか?

  • Reactでウェブサイトを構築できます
    • サーバサイドで動きます
      • SSRできます
      • APIも生やせます
    • 静的サイトとしてもレンダリングできます
  • 基本的にcreate-react-app (CRA)によく似ています
    • CRAと同じで、設定がなくて楽ちんです
    • CRAと同じで、裏で勝手にwebpackとか使ってます
  • 特定のディレクトリに決まった形式でJSファイルを置けばページとしてルーティングとサーブとレンダリングをよしなにやってくれます
    • ページに該当するJSからReactコンポーネントをexport defaultとして公開すればOKです。
      • ./pages/xxx/index.tsxhttp://myhost/xxx/
      • ./pages/foobar/baz.jshttp://myhost/foobar/baz
      • ./pages/[var].jshttp://myhost/${var}.js (パス変数を使う場合)
        • React側からは useRouter().query.var でアクセス可能。 ?xxx=yyyy みたいなものをマージされてくる。
      • ビルド時・SSR時などに評価させるにはコンポーネントとともに下記の関数をexportすればOK
        • SSR時: export const getServerSideProps: GetServerSideProps = async () => { ... }
        • ビルド時: export const getStaticProps: GetStaticProps = async () => { ... }
          • パス引数に与えるパス変数のバリエーションを指定したい場合は export const getStaticPaths: GetStaticPaths = async () => { ... } でOK。ここで返却したパス変数のバリエーション分静的レンダリングされたHTMLが出来上がったりする。
    • APIは ./pages/api 以下で公開できます。
      • export defaultで (req, res) => { ... } みたいな関数を出力すればOK

○○できるの?

ドキュメント見ればだいたい書いてありますが…

  • Babel: できる
  • PostCSS: できる
  • TypeScript: できる
  • CSS in JS: できる
    • ビルトインのものがあるけどEmotionと組み合わせたりしたほうが便利そう
  • .env による環境変数指定: できる
  • APIサーバ: できる
  • PWA: こういうのもあるようです
  • 環境変数によるソース内文字列置換: できる
    • NEXT_PUBLIC_ で始めればOK
  • AMP: できる
  • ほかのNode.jsのHTTPサーバの一部に組み込む: できる
  • エラードキュメント: できる
  • プラグイン: ある
    • next.config.js (Next.jsの設定ファイル)にプラグインをrequireして記述すればOK
  • デプロイ: できるに決まってる
    • Vercel: 公式のデプロイサーバ
      • 基本的に無料
        • 別に静的ホスティングじゃなくて、APIとか動かしても無料
      • じゃあ何が有料なのかというと、
        • (Pro)チームコラボ・並列ビルド・パスワード保護・プレビューデプロイ
        • (Enterprise)SLA保証・マルチリージョンサーバレス機能・エンタプライズサポートなど
    • 自前:
      • Node.js アプリとして: next build しておいて、 next start すればOK
      • 静的サイトの場合は、 next build しておいて、 next export したら、できあがった /out フォルダを公開すればいい

プロジェクト作成から起動まで

# プロジェクト作成
$ yarn create next-app
 → Default ... を選択

# 開発サーバ起動
$ yarn dev

# ビルド
$ yarn build

# 本番サーバ起動 (yarn buildしないと失敗する)
$ yarn start
 → http://localhost:3000

TypeScript

ほぼ構成をカスタマイズする余地がありません。おまかせですね。

# 追加
$ yarn add -D typescript @types/react @types/node

# tsconfig.json というファイルがあれば Next.js が起動時に勝手に中身を書き込んでくれる。つまりカスタマイズするだけ無駄。
$ touch tsconfig.json

$ yarn dev
 → tsconfig.json / next-env.d.ts (型定義) が作成される

基本的には @babel/preset-typescript によるトランスパイルっぽいです。

あとは好きに *.tsx 置けば勝手にやってくれます。

ディレクトリ

  • public/
    • CRAと同じ。ここに公開できる静的ファイルを置く
  • pages/
    • ページ置き場ここ。 src/pages より優先される。
  • src/
    • pages/
      • ${projectRoot}/pages/ のかわりにおくことができる
  • .next/
    • next build でできるビルドディレクトリ
  • out/
    • next export でできるビルドディレクトリ

Next.js + TypeScript + Tailwind CSS (PostCSS) + twin.macro (Babel) + emotion で適当なサイトを作ってみる

リポジトリは https://github.com/knjname/2020-05-31_tryoutNextJs にあります。

初期化

# Next.js
$ yarn create next-app

# TypeScript
$ yarn add -D typescript @types/react @types/node
$ touch tsconfig.json

# Tailwind CSS
$ yarn add -D tailwindcss

# emotion
$ yarn add @emotion/core @emotion/styled

# twin.macro
$ yarn add -D babel-plugin-macros twin.macro

Tailwind CSS / PostCSSの設定 (postcss.config.js)

requireは使ってはいけないそうです。 CSS変換結果のキャッシュの邪魔になるからだそうです。

postcss.config.js
module.exports = {
  plugins: ["tailwindcss"],
};
tailwind.config.js
module.exports = {
  purge: ["./pages/**/*.js", "./pages/**/*.tsx"], // 好きにふやしましょう
  theme: {
    extend: {},
  },
  variants: {},
  plugins: [],
};

pages/index.css も作っておきましょう。

pages/index.css
@tailwind base;

@tailwind components;

@tailwind utilities;

グローバルから参照するように設定します。

pages/_app.tsx
import { AppProps } from "next/app";
import "./index.css";

export default ({ Component, pageProps }: AppProps) => {
  return <Component {...pageProps} />;
};

twin.macro / Babelの設定 (.babelrc)

twin.macro側にNext.jsと併用する時のガイドがありますが、ベーシックなものでいきます。

.babelrc
{
  "presets": ["next/babel"],
  "plugins": ["babel-plugin-macros"]
}

上記に加え、emotionのcssプロパティを使う場合、 "presets""@emotion/babel-preset-css-prop" を追加すればいいようです。

ここまでの構成の確認

サイトのフロントカバーを作成してみます。

pages/index.tsx
import styled from "@emotion/styled";
import tw from "twin.macro";
export default () => (
  <div>
    <section
      style={{
        backgroundImage:
          'url("https://images.unsplash.com/photo-1590690726331-cd7699f1a415?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=1080&fit=max")',
      }}
      className="w-screen h-screen bg-no-repeat bg-cover flex justify-center items-center"
    >
      <Component>
        <span className="text-pink-400">M</span>y biograph
        <span className="text-green-400">y</span>
      </Component>
    </section>
  </div>
);

const Component = styled.h1`
  ${tw`text-gray-100 uppercase font-semibold text-6xl`}
`;

実行(yarn dev)すると、無事(趣味の悪い)フロントカバーが出てきます。

このサイトを静的HTMLとしてエクスポートしてみます。

$ yarn build && yarn next export

$ npx http-server out
// => http://127.0.0.1:8080/

以下の点を確認してみましょう。

  • リンクされているCSSがきちんと必要なクラスだけ含んでいるかなど確認できます。(TailwindCSS/PurgeCSS)
  • また、DevToolsなどでJavaScriptを無効にしてもきちんと表示されることを確認することができます。
    • JavaScriptが有効であれば、この後 SSRされた内容とJavaScript側のReactコンポーネントのhydrate処理(同期処理)が行われるはずです。
    • ページの内容に {new Date().toString()} などを入れると分かりやすいと思います。

サイトを発展させる

何も無いのは寂しいので、全ページの一番上にグローバルナビを追加してみましょう。

src/_document.tsx
import Document, { Html, Head, Main, NextScript } from "next/document";
import Link from "next/link";

class MyDocument extends Document {
  static async getInitialProps(ctx) {
    const initialProps = await Document.getInitialProps(ctx);
    return { ...initialProps };
  }

  render() {
    const ListClassName = "text-white px-2";

    return (
      <Html>
        <Head />
        <body>
          <ul className="block flex px-4 py-2 bg-gray-800 sticky top-0 left-0">
            <li className={ListClassName}>
              <Link href="/">
                <a>Top</a>
              </Link>
            </li>
            <li className={ListClassName}>
              <Link href="/profile">Profile</Link>
            </li>
          </ul>
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}

export default MyDocument;

(2020/06/02 追記) 上記のグローバルメニューですが、 _app.tsx の方に追記したほうがいいと思われます…

プロファイルのページも足しましょう。

src/profile.tsx
export default () => <h2 className="text-6xl">好きな食べ物はうどん!</h2>;

まとめ

SSRしたり色々するのに非常に楽なプラットフォームだと思いました。

なんとなくReactアプリをCRAではじめるのもアリですが、Next.jsのほうがハマるパターンも結構あると思います。

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

Reactive Programmingの基本

はじめに

JavaScriptを触るなかで関数型リアクティブプログラミング(FRP)について知り、面白そうと調べましたが考え方のベースとなる部分を理解することに苦労したので記録として残します。

What is Reactive?

アプリケーションは多くのモジュールによって構成されています。多くの場合、そのモジュール達はデータによって繋がっています。あるモジュールの値が更新されると、他のモジュールの値も更新されるようなシーンです。
Untitled Diagram.jpg

例としてAmazonのようなオンラインショッピングサービスを提供するアプリケーションを挙げ、アプリケーション内の『カート』を担当するモジュールと、『請求書』を担当するモジュールの2つの関係に注目します。例えばユーザーがカートに商品を追加したとき、請求書は商品の金額に応じて自動で更新されます。つまり図の矢印はデータの流れを表していて、カートのデータが更新されると請求書のデータも更新されることを表現しています。このとき請求書はプログラム的にどのように更新されるのか、Cartモジュール側がInvoiceモジュールの値を更新しているのか、Invoiceモジュール側がCartモジュールの値の変動を監視して自力で変更しているのか考えてみます。Reactiveなプログラミングの場合、後者がそれと言えます。

Reactive ProgrammingとはInvoiceモジュール側がCartモジュールの値の変動を監視して自力で変更しているようなプログラム

つまり外部のモジュールの変更を監視し、自身のデータを更新します。前者とは違い、モジュールの値の更新を他のモジュールに依存していません。ちなみに前者のようなプログラミングをAndré Staltz(cycle.jsを作ったエンジニア)はモジュールの外部のデータに対する受動的な姿勢からPassive Programmingと名付けています。次に何故このリアクティブプログラミングが近年注目されているのかを示していきます。

Why Reactive Programming?

下の図のように6つのモジュールから構成されるアプリケーションがあるとします。
Untitled Diagram (3).jpg
このアプリケーションの挙動を理解しようとしたとき、各モジュールのコードを読んでいくことになると思います。その中でCのモジュールを読んでいるとします。先ほどのPassive Programmingの場合、Cのモジュール内で扱っているデータがどこから来ているのかが分かりません。つまりCのモジュールを見ただけではCの挙動を理解できないのです。Aのモジュールを見ないと理解できません。加えてAのようなモジュールがどこに存在するか探す作業もあります。
Reactive Programmingの場合、CのモジュールはAのモジュールを監視していて、Aが変更されるたびにCのデータは更新されます。従ってCを見るだけで、Cの挙動が理解できるわけです。つまり分かりやすいというメリットがあります。これ以外にも先ほどのアマゾンの例と似ていますが検索候補(search suggestion)のようにinputの値が更新されるたびにサーバーにアクセスすることなくreactiveに検索候補を挙げてくれるプログラムを実現できるというメリットもあります。(次のエクセルの例も参考にしてみて下さい。)
ただここで疑問になるのが、他のモジュールのイベントを監視し、反応するリアクティブプログラミングはどうやって実装されるのか、です。

What is Reactive Programming?

モジュール自身で他のモジュールのデータの変更に対応しようとしても、他のモジュールのデータをフェッチするような関数を作成し他のモジュール側で実行してもReactiveとは言えません。Reactiveなプログラミングを実装するのは結構大変なのです。では、どうやって実装するのか、どういうものがReactive Programmingと言えるのか。まずはリアクティブプログラミングの具体的なイメージから入りましょう。
Reactive Programmingを用いたアプリケーションとして有名なものにエクセルがあります。エクセルをイメージすると仕組みが理解できると思います。
Untitled Diagram (4).jpg
例えば上の図を見たとき赤丸の部分が何を意味しているのか『-2』という値だけでは分かりません。エクセルではセルをクリックすると、どこのデータをフェッチしてきているのか一目で分かります。
Untitled Diagram (5).jpg
このように、どのセルからデータをフェッチしているのか一目で分かります。また『B3』のセルのデータを変更すると自動で赤丸部分の値が更新されます。見れば分かりますが、赤丸部分のデータの更新は『B3』『B9』のセルに依存しておらず、そのため赤丸部分の内容を見るだけで挙動が理解できます。このセルたちをモジュールに置き換えたものが関数型リアクティブプログラミング(FRP)であり、これをコードに置き換えることで実装できます。それでは実際にコードで実装していきましょう。

How to Reactive Programming?

今回はrxjsというライブラリを用いてリアクティブプログラミングを実装していきます。rxjsはコードを分かりやすいものとするために様々な役割を用語として定義しています。rxjsを用いてリアクティブプログラミングを実装するのには理解が必要な用語がいくつかあります。

Untitled Diagram (11).jpg

observable

いつ更新されるか分からないデータを含め全てのデータをストリーム(データの流れ)とし、そのデータのストリーム自体をラップするものをRxjsではobservableと呼ばれます。エクセルのセルのようにデータをラップするものです。例えばDBのデータをラップすることで、DBが変化するごとにリロードせずとも対応できます。ただし関数が呼び出されない限り動かないように、observableもsubscribeされない限り何も返しません。また複数の返り値を返すことができます。

subscribe

observable内にあるデータが更新されたとき、observerに対して知らせる役割を持つ。Youtubeのsubscribe機能が分かりやすいと思います。ユーザーは自分の好きなYouTuberに対してsubscribeすることでYouTuber(observable)の動画投稿の動き(データストリーム)を監視(listen)することができます。よく購読と訳されるのは、恐らくobservableをsubscribeすることで初めて購読(中身のデータを扱えることが)できるためだと思います。
observableをsubscribeするとsubscriptionオブジェクトを返し、これをunsubscribeしない限りobserverはobservableからデータを受け取り続けます。

pipe

実際にobsevableのイベントに対してreactする部分。operatorと呼ばれる、データに対し予め決められた操作のできる関数を用いてデータを操作(manipulate)します。

observer

新しいデータをobservableから受け取ったときに、それに対して処理等を行うのがobserverオブジェクトです。observableからのデータのconsumer。
3種類のコールバックを用意することができます。observableからの通知のようなものです。

  • nextメソッドは新しいデータをobservableが受け取るたびに実行されるメソッド。
  • errorメソッドはobservableがエラーを投げたときに実行されるメソッド。
  • completeメソッドがobservableの更新が完了したときに実行されるメソッド。

エクセルのセルに対するデータの入力、更新に終わりがないようにobservableが決して完了しないケースもあります。つまりcompleteメソッドが必要ないことがあります。

subject

observableと似ているが、多くのobserverに対して変更をマルチキャストすることができます。つまり多くのobserverを持つことができるとも言え、Reactで言えばReduxと概念は似ていると思います。

rxjsを使うメリット

operatorの数が多い
処理のフローが分かりやすい
データを自由に加工できる

Example

App.js
import React, { useState, useEffect } from 'react';
import { from  } from 'rxjs';
import { map, filter, mergeMap, delay } from 'rxjs/operators';

// observableを作成
let numberObservable = from([1, 2, 3, 4, 5]);

// operatorでobservableからのデータを操作
let squareNumbers = numberObservable.pipe(
    filter(val => val > 2),
    mergeMap(val => from([val]).pipe(
        delay(1000 * val)
    )),
    map(val => val * val)
);

// observableフックの作成。
// unsubscribeしなければデータストリームが止まらない限り実行され続ける
const useObservable = (observable, setter) => {
    useEffect(() => {
        let subscription = observable.subscribe(result => {
            setter(result);
        });

        return () => subscription.unsubscribe();
    }, [observable, setter]) 
}

const App = () => {
    const [currentNumber, setCurrentNumber] = useState(0);

    useObservable(squareNumbers, setCurrentNumber);

    return (
        <div className="app">
            Current number is: {currentNumber}
        </div>
    );
}

export default App;

おわりに

誤字、誤った説明があった場合、お手数ですがコメント等下さい。

参考

https://www.youtube.com/watch?v=KOjC3RhwKU4&t
https://www.youtube.com/watch?v=49dMGC1hM1o&t
https://www.youtube.com/watch?v=uQ1zhJHclvs&t
https://www.youtube.com/watch?v=eloMMybBVN0
https://www.youtube.com/watch?v=Tux1nhBPl_w
https://www.youtube.com/watch?v=GCPORlQDFHI
https://www.youtube.com/watch?v=Urv82SGIu_0&t

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

Reactive Programming with JavaScript

はじめに

JavaScriptを触るなかで関数型リアクティブプログラミング(FRP)について知り、面白そうと調べましたが考え方のベースとなる部分を理解することに苦労したので記録として残します。

What is Reactive?

アプリケーションは多くのモジュールによって構成されています。多くの場合、そのモジュール達はデータによって繋がっています。あるモジュールの値が更新されると、他のモジュールの値も更新されるようなシーンです。
Untitled Diagram.jpg

例としてAmazonのようなオンラインショッピングサービスを提供するアプリケーションを挙げ、アプリケーション内の『カート』を担当するモジュールと、『請求書』を担当するモジュールの2つの関係に注目します。例えばユーザーがカートに商品を追加したとき、請求書は商品の金額に応じて自動で更新されます。つまり図の矢印はデータの流れを表していて、カートのデータが更新されると請求書のデータも更新されることを表現しています。このとき請求書はプログラム的にどのように更新されるのか、Cartモジュール側がInvoiceモジュールの値を更新しているのか、Invoiceモジュール側がCartモジュールの値の変動を監視して自力で変更しているのか考えてみます。Reactiveなプログラミングの場合、後者がそれと言えます。

Reactive ProgrammingとはInvoiceモジュール側がCartモジュールの値の変動を監視して自力で変更しているようなプログラム

つまり外部のモジュールの変更を監視し、自身のデータを更新します。前者とは違い、モジュールの値の更新を他のモジュールに依存していません。ちなみに前者のようなプログラミングをAndré Staltz(cycle.jsを作ったエンジニア)はモジュールの外部のデータに対する受動的な姿勢からPassive Programmingと名付けています。次に何故このリアクティブプログラミングが近年注目されているのかを示していきます。

Why Reactive Programming?

下の図のように6つのモジュールから構成されるアプリケーションがあるとします。
Untitled Diagram (3).jpg
このアプリケーションの挙動を理解しようとしたとき、各モジュールのコードを読んでいくことになると思います。その中でCのモジュールを読んでいるとします。先ほどのPassive Programmingの場合、Cのモジュール内で扱っているデータがどこから来ているのかが分かりません。つまりCのモジュールを見ただけではCの挙動を理解できないのです。Aのモジュールを見ないと理解できません。加えてAのようなモジュールがどこに存在するか探す作業もあります。
Reactive Programmingの場合、CのモジュールはAのモジュールを監視していて、Aが変更されるたびにCのデータは更新されます。従ってCを見るだけで、Cの挙動が理解できるわけです。つまり分かりやすいというメリットがあります。これ以外にも先ほどのアマゾンの例と似ていますが検索候補(search suggestion)のようにinputの値が更新されるたびにサーバーにアクセスすることなくreactiveに検索候補を挙げてくれるプログラムを実現できるというメリットもあります。(次のエクセルの例も参考にしてみて下さい。)
ただここで疑問になるのが、他のモジュールのイベントを監視し、反応するリアクティブプログラミングはどうやって実装されるのか、です。

What is Reactive Programming?

モジュール自身で他のモジュールのデータの変更に対応しようとしても、他のモジュールのデータをフェッチするような関数を作成し他のモジュール側で実行してもReactiveとは言えません。Reactiveなプログラミングを実装するのは結構大変なのです。では、どうやって実装するのか、どういうものがReactive Programmingと言えるのか。まずはリアクティブプログラミングの具体的なイメージから入りましょう。
Reactive Programmingを用いたアプリケーションとして有名なものにエクセルがあります。エクセルをイメージすると仕組みが理解できると思います。
Untitled Diagram (4).jpg
例えば上の図を見たとき赤丸の部分が何を意味しているのか『-2』という値だけでは分かりません。エクセルではセルをクリックすると、どこのデータをフェッチしてきているのか一目で分かります。
Untitled Diagram (5).jpg
このように、どのセルからデータをフェッチしているのか一目で分かります。また『B3』のセルのデータを変更すると自動で赤丸部分の値が更新されます。見れば分かりますが、赤丸部分のデータの更新は『B3』『B9』のセルに依存しておらず、そのため赤丸部分の内容を見るだけで挙動が理解できます。このセルたちをモジュールに置き換えたものが関数型リアクティブプログラミング(FRP)であり、これをコードに置き換えることで実装できます。それでは実際にコードで実装していきましょう。

How to Reactive Programming?

今回はrxjsというライブラリを用いてリアクティブプログラミングを実装していきます。rxjsはコードを分かりやすいものとするために様々な役割を用語として定義しています。rxjsを用いてリアクティブプログラミングを実装するのには理解が必要な用語がいくつかあります。

Untitled Diagram (11).jpg

observable

いつ更新されるか分からないデータを含め全てのデータをストリーム(データの流れ)とし、そのデータのストリーム自体をラップするものをRxjsではobservableと呼ばれます。エクセルのセルのようにデータをラップするものです。例えばDBのデータをラップすることで、DBが変化するごとにリロードせずとも対応できます。ただし関数が呼び出されない限り動かないように、observableもsubscribeされない限り何も返しません。また複数の返り値を返すことができます。

subscribe

observable内にあるデータが更新されたとき、observerに対して知らせる役割を持つ。Youtubeのsubscribe機能が分かりやすいと思います。ユーザーは自分の好きなYouTuberに対してsubscribeすることでYouTuber(observable)の動画投稿の動き(データストリーム)を監視(listen)することができます。よく購読と訳されるのは、恐らくobservableをsubscribeすることで初めて購読(中身のデータを扱えることが)できるためだと思います。
observableをsubscribeするとsubscriptionオブジェクトを返し、これをunsubscribeしない限りobserverはobservableからデータを受け取り続けます。

pipe

実際にobsevableのイベントに対してreactする部分。operatorと呼ばれる、データに対し予め決められた操作のできる関数を用いてデータを操作(manipulate)します。

observer

新しいデータをobservableから受け取ったときに、それに対して処理等を行うのがobserverオブジェクトです。observableからのデータのconsumer。
3種類のコールバックを用意することができます。observableからの通知のようなものです。

  • nextメソッドは新しいデータをobservableが受け取るたびに実行されるメソッド。
  • errorメソッドはobservableがエラーを投げたときに実行されるメソッド。
  • completeメソッドがobservableの更新が完了したときに実行されるメソッド。

エクセルのセルに対するデータの入力、更新に終わりがないようにobservableが決して完了しないケースもあります。つまりcompleteメソッドが必要ないことがあります。

subject

observableと似ているが、多くのobserverに対して変更をマルチキャストすることができます。つまり多くのobserverを持つことができるとも言え、Reactで言えばReduxと概念は似ていると思います。

rxjsを使うメリット

operatorの数が多い
処理のフローが分かりやすい
データを自由に加工できる

Example

App.js
import React, { useState, useEffect } from 'react';
import { from  } from 'rxjs';
import { map, filter, mergeMap, delay } from 'rxjs/operators';

// observableを作成
let numberObservable = from([1, 2, 3, 4, 5]);

// operatorでobservableからのデータを操作
let squareNumbers = numberObservable.pipe(
    filter(val => val > 2),
    mergeMap(val => from([val]).pipe(
        delay(1000 * val)
    )),
    map(val => val * val)
);

// observableフックの作成。
// unsubscribeしなければデータストリームが止まらない限り実行され続ける
const useObservable = (observable, setter) => {
    useEffect(() => {
        let subscription = observable.subscribe(result => {
            setter(result);
        });

        return () => subscription.unsubscribe();
    }, [observable, setter]) 
}

const App = () => {
    const [currentNumber, setCurrentNumber] = useState(0);

    useObservable(squareNumbers, setCurrentNumber);

    return (
        <div className="app">
            Current number is: {currentNumber}
        </div>
    );
}

export default App;

おわりに

誤字、誤った説明があった場合、お手数ですがコメント等下さい。

参考

https://www.youtube.com/watch?v=KOjC3RhwKU4&t
https://www.youtube.com/watch?v=49dMGC1hM1o&t
https://www.youtube.com/watch?v=uQ1zhJHclvs&t
https://www.youtube.com/watch?v=eloMMybBVN0
https://www.youtube.com/watch?v=Tux1nhBPl_w
https://www.youtube.com/watch?v=GCPORlQDFHI
https://www.youtube.com/watch?v=Urv82SGIu_0&t

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

【Material-UI】FormControlLabelを使ったラジオフォーム作成

はじめに

今回は、React UI コンポーネントフレームワークの「Material-UI」を使って、以下のようなラジオフォームを作成します。

Image from Gyazo

ポイント

  • 基本的にはMaterial-UIデフォルトスタイルで作成
  • 複数のコンポーネントを組み合わせて作る 
  • ラベルのフォントサイズの変更について

マークアップ構成

<FormControl>コンポーネントで、Formの領域を確保して、
<RadioGroup>コンポーネントでラップした項目一覧(<Radio>コンポーネント)を配置します。
今回は、ラベルを<FormControlLabel>コンポーネントを使います。

以下の構成です。

return (
  <FormControl>
    {/* グループにラベル */}
    <FormLabel />
    {/* ラジオグループ */}
    <RadioGroup>
    {/* リスト */}
      <FormControlLabel>
        <Radio />
      </FormControlLabel>
      ...
    </RadioGroup>
  </FormControl>
)

ソースコード

それぞれが選択された場合の処理を追加して完成です。

import React from "react";
import Radio from "@material-ui/core/Radio";
import RadioGroup from "@material-ui/core/RadioGroup";
import FormControlLabel from "@material-ui/core/FormControlLabel";
import FormControl from "@material-ui/core/FormControl";
import FormLabel from "@material-ui/core/FormLabel";
import { makeStyles } from "@material-ui/core/styles";
import "./styles.css";

export default function App() {
  const [value, setValue] = React.useState("React");
  const classes = styles();

  const handleChange = event => {
    // クリックされたら、valueの値をsetします。
    setValue(event.target.value);
  };

  return (
    <div className="App">
      <FormControl component="label">
        <FormLabel component="label">JavaScript framework</FormLabel>
        <RadioGroup
          name="JavaScript framework"
          value={value}
          onChange={handleChange}
        >
          <FormControlLabel
            value="React"
            control={<Radio />}
            label="React"
          />
          <FormControlLabel
            value="Vue.js"
            control={<Radio />}
            label="Vue.js"
          />
          <FormControlLabel
            value="AngularJS"
            control={<Radio />}
            label="AngularJS"
          />
          <FormControlLabel
            value="jQuery"
            disabled
            control={<Radio />}
            label="jQuery"
          />
        </RadioGroup>
      </FormControl>
    </div>
  );
}

ラベルのフォントサイズを変更したい

Material-UIのデフォルトデザインで利用できれば上記で良いですが、
なかなかデザインいじらずに使えると機会は少ないかと思います。

今回は、ラベルのフォントサイズを変更してみます。

FormControlLabelの構造について

<FromControlLabel>コンポーネントは、大きく、controlプロパティで指定したエレメント(RadioSwitchCheckbox)と「ラベル」の二つで構成されています。

公式リファレンスによると、ラベルのデフォルトスタイルは、Typographyスタイルが当たっているようです。

Styles applied to the label's Typography component.

labelプロパティにエレメントを当てて上書きする

テキストでそのまま送らずに、<span>タグ等で囲ってあげて、スタイルを当ててあげることで上書きしてみました。

<FormControlLabel
  value="React"
  control={<Radio />}
  label={<span className={classes.labelRoot}>React</span>}
/>

紹介した内容のSnadbox

Edit Material-UI_FormControl

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

日本一わかりやすいReact入門【実践編】#10~11

概要

Youtubeチャンネル『トラハックのエンジニア学習ゼミ【とらゼミ】』の『日本一わかりやすいReact入門【実践編】』の学習備忘録。

#6~9までの記事はこちら

#10...Cloud FunctionsでAPIを作ってFirestoreを利用しよう

REST APIを作る

今まではローカル上で管理していたdataset.jsを、クラウドDBであるCloud Functionsで管理する。Cloud Functionで扱うデータ形式はJSONのため、dataset.jsに対応するJSONファイルをgithubからコピーする。

src/dataset.json
{
  "init": {
      "answers": [
          {"content": "仕事を依頼したい", "nextId": "job_offer"},
          {"content": "エンジニアのキャリアについて相談したい", "nextId": "consultant"},
          {"content": "学習コミュニティについて知りたい", "nextId": "community"},
          {"content": "お付き合いしたい", "nextId": "dating"}
      ],
      "question": "こんにちは!?トラハックへのご用件はなんでしょうか?"
  },
  "job_offer": {
      "answers": [
          {"content": "Webサイトを制作してほしい", "nextId": "website"},
          {"content": "Webアプリを開発してほしい", "nextId": "webapp"},
          {"content": "自動化ツールを作ってほしい", "nextId": "automation_tool"},
          {"content": "その他", "nextId": "other_jobs"}
      ],
      "question": "どのようなお仕事でしょうか?"
  },
  "website": {
      "answers": [
          {"content": "問い合わせる", "nextId": "contact"},
          {"content": "最初の質問に戻る", "nextId": "init"}
      ],
      "question": "Webサイト細作についてですね。コチラからお問い合わせできます。"
  },
  "webapp": {
      "answers": [
          {"content": "問い合わせる", "nextId": "contact"},
          {"content": "最初の質問に戻る", "nextId": "init"}
      ],
      "question": "Webアプリ開発についてですね。コチラからお問い合わせできます。"
  },
  "automation_tool": {
      "answers": [
          {"content": "問い合わせる", "nextId": "contact"},
          {"content": "最初の質問に戻る", "nextId": "init"}
      ],
      "question": "自動化ツール開発についてですね。コチラからお問い合わせできます。"
  },
  "other_jobs": {
      "answers": [
          {"content": "問い合わせる", "nextId": "contact"},
          {"content": "最初の質問に戻る", "nextId": "init"}
      ],
      "question": "その他についてですね。コチラからお問い合わせできます。"
  },
  "consultant": {
      "answers": [
          {"content": "YouTubeで動画を見る", "nextId": "https://www.youtube.com/channel/UC-bOAxx-YOsviSmqh8COR0w"},
          {"content": "学習コミュニティについて知りたい", "nextId": "community"},
          {"content": "最初の質問に戻る", "nextId": "init"}
      ],
      "question": "トラハックは普段からYouTubeでキャリアについて発信しています。また、僕が運営するエンジニア向け学習コミュニティ内でも相談に乗っていますよ。"
  },
  "community": {
      "answers": [
          {"content": "どんな活動をしているの?", "nextId": "community_activity"},
          {"content": "コミュニティに参加したい", "nextId": "https://torahack.web.app/community/"},
          {"content": "最初の質問に戻る", "nextId": "init"}
      ],
      "question": "2020年3月から学習コミュニティを始めました!?Webエンジニアへの転職を目指す人向けに、プログラミングを教えたりキャリアの相談に乗っています。"
  },
  "community_activity": {
      "answers": [
          {"content": "さらに詳細を知りたい", "nextId": "https://youtu.be/tIzE7hUDbBM"},
          {"content": "コミュニティに参加したい", "nextId": "https://torahack.web.app/community/"},
          {"content": "最初の質問に戻る", "nextId": "init"}
      ],
      "question": "フロントエンド向けの教材の提供、キャリアや勉強法に関するメルマガの配信、週1のオンライン作業会などを開催しています!\n詳細はYouTube動画で紹介しています。"
  },
  "dating": {
      "answers": [
          {"content": "DMする", "nextId": "https://twitter.com/torahack_"},
          {"content": "最初の質問に戻る", "nextId": "init"}
      ],
      "question": "まずは一緒にランチでもいかがですか?DMしてください?"
  }
}

JSON形式のため、dataset.jsとは書き方が少し異なるが、中身のデータは同じものを保有している。

次に、Clound Functinon用のhttp関数を作成します。これは、リクエストに乗って送信されたJSON形式のデータを、DBに適切に保存するための関数です。

functions/src/index.ts
import * as functions from 'firebase-functions';
import * as admin from "firebase-admin";
admin.initializeApp();
const db = admin.firestore();

const sendResponse = (response: functions.Response, statusCode: number, body: any) => {
  response.send({
    statusCode,
    body: JSON.stringify(body)
  })
}

export const addDataset = functions.https.onRequest(async (req: any, res: any) => {
  // リクエストメソッドがPOSTでないときは、エラーを返す
  if (res.method !== 'POST') {
    sendResponse(res, 405, {error: 'Invalid Request'})
  } else {
    const dataset = req.body
    for (const key of Object.keys(dataset)) {
      const data = dataset[key]  // 'init'や'job_offer'などが入る

      // 'question'というcollectionの中に'init'や'job_offer'というdocumentがあり、
      // 各documentの中に具体的なデータが入っている。
      await db.collection('questions').doc(key).set(data)
    }
    sendResponse(res, 200, {error: 'Successfully added dataset'})
  }
})

リクエストが適切であるかどうか(POSTかどうか)で条件分岐を行い、適切であればdbの中に、リクエストのbodyの値(すなわちDBへ保存したいJSONデータ)を入れる、という処理を定義しています、

では、実際にこの関数が機能するかどうかを確かめます。一度デプロイし、先ほど作成したfunctions/src/index.tsを本番環境に含めます。

terminal
$ firebase deploy

デプロイが完了したら、ブラウザでFirebaseを開き、コンソールから「Function」→「ダッシュボード」と進んで、先ほど定義したhttp関数(今回はaddDataset関数)のリクエストURLを取得します。

dataset.jsonが置かれているディレクトリに移動し、curlコマンドで、APIを叩く

src
$ curl -X POST -H "Content-Type:application/json" -d @dataset.json https://YOUR_REGION-YOUR_PROJECT_NAME.cloudfunctions.net/addDataset

https://YOUR_REGION-YOUR_PROJECT_NAME.cloudfunctions.net/addDatasetのところが、先ほどコンソールから調べたリクエストHTTPです。

これにより、database.jsonのデータがFirestoreのDBに入る・・・予定だったのですが、しかしここでAPIより以下のメッセージが返ってきました。

terminal
<html><head>
<meta http-equiv="content-type" content="text/html;charset=utf-8">
<title>403 Forbidden</title>
</head>
<body text=#000000 bgcolor=#ffffff>
<h1>Error: Forbidden</h1>
<h2>Your client does not have permission to get URL <code>/addDataset</code> from this server.</h2>
<h2></h2>
</body></html>

どうやらCloud Functionへの書き込みをする権限が認められていないらしいです(動画ではこのエラーは出ていませんでした)

自力で調べてみた結果、Google Cloud Console上での権限付与が必要らしいです。こちらの『Firebase functionsで 403 error "Your client does not have permission to get URL /** from this server" となった場合の解決策』という記事が参考になりました。

権限付与を無事終え、再度curlコマンドを実行すると、

terminal
{"statusCode":200,"body":"{\"message\":\"Successfully added dataset!\"}"}%           

上手くいったそう! Firebase Console -> Databaseを見てみると、

image.png

dataset.jsonの中身が、Cloud Functionに保存されているのが確認できました!

この一連の権限付与の手順は動画内では行われていないので、もしかしたら必要ないものだったかもしれませんが、念のため残しておきます。

Firestoreの設定

Firebase Console -> Settings -> 全般より、Firebase SDK snippetを取得します(トグルは構成を選択)。srcディレクトリにfirebase/config.jsを作り、これを貼り付けます。

src/firebase/config.js
const firebaseConfig = {
  apiKey: "**********************************",
  authDomain: "**********************************",
  databaseURL: "**********************************",
  projectId: "**********************************",
  storageBucket: "**********************************",
  messagingSenderId: "**********************************",
  appId: "**********************************",
  measurementId: "**********************************"
};

export default firebaseConfig

この設定はファイル外部でも使うため、最後にexport文も入れています。

次に、上記の設定値をもとにFirestore内のDBを引っ張ってくるファイルとして、src/firebase/index.js を作ります。

src/firebase/index.jp
import firebase from "firebase/app"
import "firebase/firestore";
import firebaseConfig from "./config";

firebase.initializeApp(firebaseConfig);
export const db = firebase.firestore();

以降、このsrc/firebase/index.jsをimportしたコンポーネントでは、dbという定数でFirestore上のDBの中身を扱えるようになりました。

これをApp.jsxでimportする。

src/App.js
import React from 'react';
// import defaultDataset from "./dataset";
import './assets/styles/style.css';
import {AnswersList, Chats, FormDialog} from "./components/index"
import {db} from "./firebase/index"   // dbをimportする

export default class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      answers: [],
      chats: [],
      currentId: "init",
      // dataset: defaultDataset,
      dataset: {},
      open: false
    }
...

  componentDidMount() {
    // async付きの即時関数を用いて、dbを読み込む
    (async() => {
      const dataset = this.state.dataset
      // awaitの処理が終わるまでは、次の処理が実行されない
      await db.collection('questions').get().then(snapshots => {
        snapshots.forEach(doc => {
          dataset[doc.id] = doc.data()
        })
      })
      this.setState({dataset: dataset})

      // db読み込みが完了してから、初期Answerの生成を開始する。
      const initAnswer = "";
      this.selectAnswer(initAnswer, this.state.currentId)
    })()
  }

...
  • reactでは、データベースからデータを持ってくるような処理は、大体componentDidMount()に書く。
  • Firestoreの読み込みは非同期処理のため、読み込みの途中で次の処理が進んでしまいエラーとなるケースがある。そのため、javascriptの即時関数を用いて、読み込みが完了するまで次に進まないよう制御する。

localhost:3000で動作を確認し、問題なく挙動していればOK!

これで目的のチャットボットアプリは完成しました!

#11...useCallbackでパフォーマンスを向上させよう

これまでは、Class Componentを用いてstateの管理を行っていました。

しかし昨今のreactでは、なるべくClass Componentの使用を避け、Functional Componentをメインとして開発することが望まれています。
理由はいくつかあると思いますが、「Functional Componentの方が記述がシンプルかつ読みやすいから」というのが大きな理由の一つです。

ここからは、React Hooksを用いて、Class Componentと全てFunctional Componentに書き換えることを目指します。

React Hooksを使う

React Hooksとは、一言で言えばFunctional Componentでもstateを扱えるようにするライブラリです。今回の開発アプリでは、Class Componentは全て「stateを管理すること」を目的として作られたものでしたので、React Hooksを用いれば、Class Componentを全てFunctional Componentに置き換えることができます。

React Hooksをimportすることで、以下の3つのメソッドが使用できるようになります。

  1. useState() : Functinal Componentstateを定義、保持する役割
  2. setEffect() : 各種ライフサイクルメソッドの代替
  3. useCallback() : 定義した関数を子コンポーネントに渡す役割(bind関数の代替)

修正するのは、Class Componentとなっている以下の2つ。
1. src/App.jsx
2. components/Forms/FormDialog.jsx

src/App.jsx
import React, {useState, useEffect,useCallback} from 'react';
import './assets/styles/style.css';
import {AnswersList, Chats, FormDialog} from "./components/index"
import {db} from "./firebase/index"

const App = () =>  {
  const [answers, setAnswers] = useState([]);
  const [chats, setChats] = useState([]);
  const [currentId, setCurrentId] = useState("init");
  const [dataset, setDataset] = useState({});
  const [open, setOpen] = useState(false);

  const displayNextQuestion = (nextQuestionId, nextDataset) => {
    addChats({
      text: nextDataset.question,
      type: 'question'
    })

    setAnswers(nextDataset.answers)
    setCurrentId(nextQuestionId)
  }

  const selectAnswer = (selectedAnswer, nextQuestionId) => {
    switch(true) {
      case (nextQuestionId === 'contact'):
        handleClickOpen()
        break;

      case (/^https:*/.test(nextQuestionId)):
        const a = document.createElement('a');
        a.href = nextQuestionId;
        a.target = '_blank';
        a.click();
        break;

      default:
        addChats({
          text: selectedAnswer,
          type: 'answer'
        })
        setTimeout(() => displayNextQuestion(nextQuestionId,dataset[nextQuestionId]), 1000);
        break;
    }
  }

  const addChats = (chat) => {
    /// prevChatsで、更新前のchatsも取得できる
    setChats(prevChats => {
      return [...prevChats, chat]
    })
  }

  const handleClickOpen = () => {
    setOpen(true)
  };

  const handleClose = useCallback(() => {
    setOpen(false)
  }, [setOpen]);

  // componentDidMountの役割。初回render時に1回だけ実行したいので、第2引数に[]を渡す。
  useEffect(() => {
    (async() => {
      const initDataset = {};

      await db.collection('questions').get().then(snapshots => {
        snapshots.forEach(doc => {
          initDataset[doc.id] = doc.data()
        })
      })

      setDataset(initDataset)
      displayNextQuestion(currentId, initDataset[currentId])
    })()
  },[])

  // componentDidUpdateの役割。再renderの度に繰り返し実行したいので、第2引数には何も渡さない。
  useEffect(() => {
    const scrollArea = document.getElementById("scroll-area")
    if (scrollArea) {
      scrollArea.scrollTop = scrollArea.scrollHeight
    }
  })

  return (
    <section className="c-section">
      <div className="c-box">
        <Chats chats={chats}/>
        <AnswersList answers={answers} select={selectAnswer} />
        <FormDialog open={open} handleClose={handleClose} />
      </div>
    </section>
  );
}

export default App
  • handleClose()はFormDialogへ渡すため、userCallbackを使用する。一方、handleOpen()はApp.jsxないでしか使わないので、通常の関数として定義する。
  • useState()では、更新前にあたる今現在のstateの値をprev○○○○ => {}の形で利用することができる。今回のchatsのように、現在の値に対して新しい値を追加していくようなときは非常に便利。
  • useStateによるstateの更新は、少しだけ時間がかかる。そのため、初期Answerの表示をさせるときは、displayNextQuestion()に対して、stateのdatasetではなく、firestoreから読み込んだinitDataset[currentId]を直接渡している。

全体的にClass Componentよりもすっきりとした印象です。個人的にはthisを使わなくて済むので、読みやすくなったと感じます。

同じように、FormDialogコンポーネントの方も修正します。

components/FormDialog.jsx
import React, {useState, useCallback} from 'react';
import Button from '@material-ui/core/Button';
import Dialog from '@material-ui/core/Dialog';
import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent';
import DialogTitle from '@material-ui/core/DialogTitle';
import {TextInput} from "../index"

const FormDialog = (props) => {
  const [name,setName] = useState("")
  const [email,setEmail] = useState("")
  const [description,setDescription] = useState("")

  const inputName = useCallback((event) => {
    setName(event.target.value)
  },[]);

  const inputEmail = useCallback((event) => {
    setEmail(event.target.value)
  },[]);

  const inputDescription = useCallback((event) => {
    setDescription(event.target.value)
  },[]);

  const submitForm = () => {
    const payload = {
      text: 'お問い合わせがありました\n' +
            'お名前: ' + name + '\n' +
            'Email: ' + email + '\n' +
            'お問い合わせ内容:\n' + description
    }

    const url = 'WebHookURLをここに書く'

    fetch(url, {
      method: 'POST',
      body: JSON.stringify(payload)
    }).then(() => {
      alert('送信が完了しました!しばらくお待ちください')
      setName("")
      setEmail("")
      setDescription("")
      return props.handleClose()
    })
  };

  return (
    <Dialog
      open={props.open}
      onClose={props.handleClose}
      aria-labelledby="alert-dialog-title"
      aria-describedby="alert-dialog-description"
    >
      <DialogTitle id="alert-dialog-title">{"お問い合せフォーム"}</DialogTitle>
      <DialogContent>

        <TextInput
          label={"お名前"}
          multiline={false}
          rows={1}
          value={name}
          type={"text"}
          onChange={inputName}
        />

        <TextInput
          label={"メールアドレス"}
          multiline={false}
          rows={1}
          value={email}
          type={"email"}
          onChange={inputEmail}
        />

        <TextInput
          label={"お問い合わせ内容"}
          multiline={true}
          rows={5}
          value={description}
          type={"text"}
          onChange={inputDescription}
        />

      </DialogContent>
      <DialogActions>
        <Button onClick={props.handleClose} color="primary">
          キャンセル
        </Button>
        <Button onClick={submitForm} color="primary" autoFocus>
          送信する
        </Button>
      </DialogActions>
    </Dialog>
  );
}

export default FormDialog

以上で完成です!!!

おまけ 本番環境へのデプロイ

動画としてはここでおしまいですが、せっかくなので、本番環境へデプロイします。

といっても、以下の2コマンドを実行するだけです。この手軽さがfirebaseのすごいところですね〜

terminal
$ npm run build
terminal
$ firebase deploy

image.png

本番環境でも無事動きました!

本動画の感想

とても分かりやすい教材でした!

なぜこれが無料で見れるのか?と思えるほどのクオリティだと感じました。

元々は自分の復習用に備忘録を書き溜めていたのですが、私と同じように「これからreactを勉強したい!」と志す方々に少しでも広まればいいなと思い、公開記事として投稿した次第です。

次は、「React+ReduxでEC2サイトを作る」という講座も始められているみたいですので、そちらもチェックしたいと思います!

参考URL

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

日本一わかりやすいReact入門【実践編】#10~11 学習備忘録

概要

Youtubeチャンネル『トラハックのエンジニア学習ゼミ【とらゼミ】』の『日本一わかりやすいReact入門【実践編】』の学習備忘録。

#6~9までの記事はこちら

#10...Cloud FunctionsでAPIを作ってFirestoreを利用しよう

REST APIを作る

今まではローカル上で管理していたdataset.jsを、クラウドDBであるCloud Functionsで管理する。Cloud Functionで扱うデータ形式はJSONのため、dataset.jsに対応するJSONファイルをgithubからコピーする。

src/dataset.json
{
  "init": {
      "answers": [
          {"content": "仕事を依頼したい", "nextId": "job_offer"},
          {"content": "エンジニアのキャリアについて相談したい", "nextId": "consultant"},
          {"content": "学習コミュニティについて知りたい", "nextId": "community"},
          {"content": "お付き合いしたい", "nextId": "dating"}
      ],
      "question": "こんにちは!?トラハックへのご用件はなんでしょうか?"
  },
  "job_offer": {
      "answers": [
          {"content": "Webサイトを制作してほしい", "nextId": "website"},
          {"content": "Webアプリを開発してほしい", "nextId": "webapp"},
          {"content": "自動化ツールを作ってほしい", "nextId": "automation_tool"},
          {"content": "その他", "nextId": "other_jobs"}
      ],
      "question": "どのようなお仕事でしょうか?"
  },
  "website": {
      "answers": [
          {"content": "問い合わせる", "nextId": "contact"},
          {"content": "最初の質問に戻る", "nextId": "init"}
      ],
      "question": "Webサイト細作についてですね。コチラからお問い合わせできます。"
  },
  "webapp": {
      "answers": [
          {"content": "問い合わせる", "nextId": "contact"},
          {"content": "最初の質問に戻る", "nextId": "init"}
      ],
      "question": "Webアプリ開発についてですね。コチラからお問い合わせできます。"
  },
  "automation_tool": {
      "answers": [
          {"content": "問い合わせる", "nextId": "contact"},
          {"content": "最初の質問に戻る", "nextId": "init"}
      ],
      "question": "自動化ツール開発についてですね。コチラからお問い合わせできます。"
  },
  "other_jobs": {
      "answers": [
          {"content": "問い合わせる", "nextId": "contact"},
          {"content": "最初の質問に戻る", "nextId": "init"}
      ],
      "question": "その他についてですね。コチラからお問い合わせできます。"
  },
  "consultant": {
      "answers": [
          {"content": "YouTubeで動画を見る", "nextId": "https://www.youtube.com/channel/UC-bOAxx-YOsviSmqh8COR0w"},
          {"content": "学習コミュニティについて知りたい", "nextId": "community"},
          {"content": "最初の質問に戻る", "nextId": "init"}
      ],
      "question": "トラハックは普段からYouTubeでキャリアについて発信しています。また、僕が運営するエンジニア向け学習コミュニティ内でも相談に乗っていますよ。"
  },
  "community": {
      "answers": [
          {"content": "どんな活動をしているの?", "nextId": "community_activity"},
          {"content": "コミュニティに参加したい", "nextId": "https://torahack.web.app/community/"},
          {"content": "最初の質問に戻る", "nextId": "init"}
      ],
      "question": "2020年3月から学習コミュニティを始めました!?Webエンジニアへの転職を目指す人向けに、プログラミングを教えたりキャリアの相談に乗っています。"
  },
  "community_activity": {
      "answers": [
          {"content": "さらに詳細を知りたい", "nextId": "https://youtu.be/tIzE7hUDbBM"},
          {"content": "コミュニティに参加したい", "nextId": "https://torahack.web.app/community/"},
          {"content": "最初の質問に戻る", "nextId": "init"}
      ],
      "question": "フロントエンド向けの教材の提供、キャリアや勉強法に関するメルマガの配信、週1のオンライン作業会などを開催しています!\n詳細はYouTube動画で紹介しています。"
  },
  "dating": {
      "answers": [
          {"content": "DMする", "nextId": "https://twitter.com/torahack_"},
          {"content": "最初の質問に戻る", "nextId": "init"}
      ],
      "question": "まずは一緒にランチでもいかがですか?DMしてください?"
  }
}

JSON形式のため、dataset.jsとは書き方が少し異なるが、中身のデータは同じものを保有している。

次に、Clound Functinon用のhttp関数を作成します。これは、リクエストに乗って送信されたJSON形式のデータを、DBに適切に保存するための関数です。

functions/src/index.ts
import * as functions from 'firebase-functions';
import * as admin from "firebase-admin";
admin.initializeApp();
const db = admin.firestore();

const sendResponse = (response: functions.Response, statusCode: number, body: any) => {
  response.send({
    statusCode,
    body: JSON.stringify(body)
  })
}

export const addDataset = functions.https.onRequest(async (req: any, res: any) => {
  // リクエストメソッドがPOSTでないときは、エラーを返す
  if (res.method !== 'POST') {
    sendResponse(res, 405, {error: 'Invalid Request'})
  } else {
    const dataset = req.body
    for (const key of Object.keys(dataset)) {
      const data = dataset[key]  // 'init'や'job_offer'などが入る

      // 'question'というcollectionの中に'init'や'job_offer'というdocumentがあり、
      // 各documentの中に具体的なデータが入っている。
      await db.collection('questions').doc(key).set(data)
    }
    sendResponse(res, 200, {error: 'Successfully added dataset'})
  }
})

リクエストが適切であるかどうか(POSTかどうか)で条件分岐を行い、適切であればdbの中に、リクエストのbodyの値(すなわちDBへ保存したいJSONデータ)を入れる、という処理を定義しています、

では、実際にこの関数が機能するかどうかを確かめます。一度デプロイし、先ほど作成したfunctions/src/index.tsを本番環境に含めます。

terminal
$ firebase deploy

デプロイが完了したら、ブラウザでFirebaseを開き、コンソールから「Function」→「ダッシュボード」と進んで、先ほど定義したhttp関数(今回はaddDataset関数)のリクエストURLを取得します。

dataset.jsonが置かれているディレクトリに移動し、curlコマンドで、APIを叩く

src
$ curl -X POST -H "Content-Type:application/json" -d @dataset.json https://YOUR_REGION-YOUR_PROJECT_NAME.cloudfunctions.net/addDataset

https://YOUR_REGION-YOUR_PROJECT_NAME.cloudfunctions.net/addDatasetのところが、先ほどコンソールから調べたリクエストHTTPです。

これにより、database.jsonのデータがFirestoreのDBに入る・・・予定だったのですが、しかしここでAPIより以下のメッセージが返ってきました。

terminal
<html><head>
<meta http-equiv="content-type" content="text/html;charset=utf-8">
<title>403 Forbidden</title>
</head>
<body text=#000000 bgcolor=#ffffff>
<h1>Error: Forbidden</h1>
<h2>Your client does not have permission to get URL <code>/addDataset</code> from this server.</h2>
<h2></h2>
</body></html>

どうやらCloud Functionへの書き込みをする権限が認められていないらしいです(動画ではこのエラーは出ていませんでした)

自力で調べてみた結果、Google Cloud Console上での権限付与が必要らしいです。こちらの『Firebase functionsで 403 error "Your client does not have permission to get URL /** from this server" となった場合の解決策』という記事が参考になりました。

権限付与を無事終え、再度curlコマンドを実行すると、

terminal
{"statusCode":200,"body":"{\"message\":\"Successfully added dataset!\"}"}%           

上手くいったそう! Firebase Console -> Databaseを見てみると、

image.png

dataset.jsonの中身が、Cloud Functionに保存されているのが確認できました!

この一連の権限付与の手順は動画内では行われていないので、もしかしたら必要ないものだったかもしれませんが、念のため残しておきます。

Firestoreの設定

Firebase Console -> Settings -> 全般より、Firebase SDK snippetを取得します(トグルは構成を選択)。srcディレクトリにfirebase/config.jsを作り、これを貼り付けます。

src/firebase/config.js
const firebaseConfig = {
  apiKey: "**********************************",
  authDomain: "**********************************",
  databaseURL: "**********************************",
  projectId: "**********************************",
  storageBucket: "**********************************",
  messagingSenderId: "**********************************",
  appId: "**********************************",
  measurementId: "**********************************"
};

export default firebaseConfig

この設定はファイル外部でも使うため、最後にexport文も入れています。

次に、上記の設定値をもとにFirestore内のDBを引っ張ってくるファイルとして、src/firebase/index.js を作ります。

src/firebase/index.jp
import firebase from "firebase/app"
import "firebase/firestore";
import firebaseConfig from "./config";

firebase.initializeApp(firebaseConfig);
export const db = firebase.firestore();

以降、このsrc/firebase/index.jsをimportしたコンポーネントでは、dbという定数でFirestore上のDBの中身を扱えるようになりました。

これをApp.jsxでimportする。

src/App.js
import React from 'react';
// import defaultDataset from "./dataset";
import './assets/styles/style.css';
import {AnswersList, Chats, FormDialog} from "./components/index"
import {db} from "./firebase/index"   // dbをimportする

export default class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      answers: [],
      chats: [],
      currentId: "init",
      // dataset: defaultDataset,
      dataset: {},
      open: false
    }
...

  componentDidMount() {
    // async付きの即時関数を用いて、dbを読み込む
    (async() => {
      const dataset = this.state.dataset
      // awaitの処理が終わるまでは、次の処理が実行されない
      await db.collection('questions').get().then(snapshots => {
        snapshots.forEach(doc => {
          dataset[doc.id] = doc.data()
        })
      })
      this.setState({dataset: dataset})

      // db読み込みが完了してから、初期Answerの生成を開始する。
      const initAnswer = "";
      this.selectAnswer(initAnswer, this.state.currentId)
    })()
  }

...
  • reactでは、データベースからデータを持ってくるような処理は、大体componentDidMount()に書く。
  • Firestoreの読み込みは非同期処理のため、読み込みの途中で次の処理が進んでしまいエラーとなるケースがある。そのため、javascriptの即時関数を用いて、読み込みが完了するまで次に進まないよう制御する。

localhost:3000で動作を確認し、問題なく挙動していればOK!

これで目的のチャットボットアプリは完成しました!

#11...useCallbackでパフォーマンスを向上させよう

これまでは、Class Componentを用いてstateの管理を行っていました。

しかし昨今のreactでは、なるべくClass Componentの使用を避け、Functional Componentをメインとして開発することが望まれています。
理由はいくつかあると思いますが、「Functional Componentの方が記述がシンプルかつ読みやすいから」というのが大きな理由の一つです。

ここからは、React Hooksを用いて、Class Componentと全てFunctional Componentに書き換えることを目指します。

React Hooksを使う

React Hooksとは、一言で言えばFunctional Componentでもstateを扱えるようにするライブラリです。今回の開発アプリでは、Class Componentは全て「stateを管理すること」を目的として作られたものでしたので、React Hooksを用いれば、Class Componentを全てFunctional Componentに置き換えることができます。

React Hooksをimportすることで、以下の3つのメソッドが使用できるようになります。

  1. useState() : Functinal Componentstateを定義、保持する役割
  2. setEffect() : 各種ライフサイクルメソッドの代替
  3. useCallback() : 定義した関数を子コンポーネントに渡す役割(bind関数の代替)

修正するのは、Class Componentとなっている以下の2つ。
1. src/App.jsx
2. components/Forms/FormDialog.jsx

src/App.jsx
import React, {useState, useEffect,useCallback} from 'react';
import './assets/styles/style.css';
import {AnswersList, Chats, FormDialog} from "./components/index"
import {db} from "./firebase/index"

const App = () =>  {
  const [answers, setAnswers] = useState([]);
  const [chats, setChats] = useState([]);
  const [currentId, setCurrentId] = useState("init");
  const [dataset, setDataset] = useState({});
  const [open, setOpen] = useState(false);

  const displayNextQuestion = (nextQuestionId, nextDataset) => {
    addChats({
      text: nextDataset.question,
      type: 'question'
    })

    setAnswers(nextDataset.answers)
    setCurrentId(nextQuestionId)
  }

  const selectAnswer = (selectedAnswer, nextQuestionId) => {
    switch(true) {
      case (nextQuestionId === 'contact'):
        handleClickOpen()
        break;

      case (/^https:*/.test(nextQuestionId)):
        const a = document.createElement('a');
        a.href = nextQuestionId;
        a.target = '_blank';
        a.click();
        break;

      default:
        addChats({
          text: selectedAnswer,
          type: 'answer'
        })
        setTimeout(() => displayNextQuestion(nextQuestionId,dataset[nextQuestionId]), 1000);
        break;
    }
  }

  const addChats = (chat) => {
    /// prevChatsで、更新前のchatsも取得できる
    setChats(prevChats => {
      return [...prevChats, chat]
    })
  }

  const handleClickOpen = () => {
    setOpen(true)
  };

  const handleClose = useCallback(() => {
    setOpen(false)
  }, [setOpen]);

  // componentDidMountの役割。初回render時に1回だけ実行したいので、第2引数に[]を渡す。
  useEffect(() => {
    (async() => {
      const initDataset = {};

      await db.collection('questions').get().then(snapshots => {
        snapshots.forEach(doc => {
          initDataset[doc.id] = doc.data()
        })
      })

      setDataset(initDataset)
      displayNextQuestion(currentId, initDataset[currentId])
    })()
  },[])

  // componentDidUpdateの役割。再renderの度に繰り返し実行したいので、第2引数には何も渡さない。
  useEffect(() => {
    const scrollArea = document.getElementById("scroll-area")
    if (scrollArea) {
      scrollArea.scrollTop = scrollArea.scrollHeight
    }
  })

  return (
    <section className="c-section">
      <div className="c-box">
        <Chats chats={chats}/>
        <AnswersList answers={answers} select={selectAnswer} />
        <FormDialog open={open} handleClose={handleClose} />
      </div>
    </section>
  );
}

export default App
  • handleClose()はFormDialogへ渡すため、userCallbackを使用する。一方、handleOpen()はApp.jsxないでしか使わないので、通常の関数として定義する。
  • useState()では、更新前にあたる今現在のstateの値をprev○○○○ => {}の形で利用することができる。今回のchatsのように、現在の値に対して新しい値を追加していくようなときは非常に便利。
  • useStateによるstateの更新は、少しだけ時間がかかる。そのため、初期Answerの表示をさせるときは、displayNextQuestion()に対して、stateのdatasetではなく、firestoreから読み込んだinitDataset[currentId]を直接渡している。

全体的にClass Componentよりもすっきりとした印象です。個人的にはthisを使わなくて済むので、読みやすくなったと感じます。

同じように、FormDialogコンポーネントの方も修正します。

components/FormDialog.jsx
import React, {useState, useCallback} from 'react';
import Button from '@material-ui/core/Button';
import Dialog from '@material-ui/core/Dialog';
import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent';
import DialogTitle from '@material-ui/core/DialogTitle';
import {TextInput} from "../index"

const FormDialog = (props) => {
  const [name,setName] = useState("")
  const [email,setEmail] = useState("")
  const [description,setDescription] = useState("")

  const inputName = useCallback((event) => {
    setName(event.target.value)
  },[]);

  const inputEmail = useCallback((event) => {
    setEmail(event.target.value)
  },[]);

  const inputDescription = useCallback((event) => {
    setDescription(event.target.value)
  },[]);

  const submitForm = () => {
    const payload = {
      text: 'お問い合わせがありました\n' +
            'お名前: ' + name + '\n' +
            'Email: ' + email + '\n' +
            'お問い合わせ内容:\n' + description
    }

    const url = 'WebHookURLをここに書く'

    fetch(url, {
      method: 'POST',
      body: JSON.stringify(payload)
    }).then(() => {
      alert('送信が完了しました!しばらくお待ちください')
      setName("")
      setEmail("")
      setDescription("")
      return props.handleClose()
    })
  };

  return (
    <Dialog
      open={props.open}
      onClose={props.handleClose}
      aria-labelledby="alert-dialog-title"
      aria-describedby="alert-dialog-description"
    >
      <DialogTitle id="alert-dialog-title">{"お問い合せフォーム"}</DialogTitle>
      <DialogContent>

        <TextInput
          label={"お名前"}
          multiline={false}
          rows={1}
          value={name}
          type={"text"}
          onChange={inputName}
        />

        <TextInput
          label={"メールアドレス"}
          multiline={false}
          rows={1}
          value={email}
          type={"email"}
          onChange={inputEmail}
        />

        <TextInput
          label={"お問い合わせ内容"}
          multiline={true}
          rows={5}
          value={description}
          type={"text"}
          onChange={inputDescription}
        />

      </DialogContent>
      <DialogActions>
        <Button onClick={props.handleClose} color="primary">
          キャンセル
        </Button>
        <Button onClick={submitForm} color="primary" autoFocus>
          送信する
        </Button>
      </DialogActions>
    </Dialog>
  );
}

export default FormDialog

以上で完成です!!!

おまけ 本番環境へのデプロイ

動画としてはここでおしまいですが、せっかくなので、本番環境へデプロイします。

といっても、以下の2コマンドを実行するだけです。この手軽さがfirebaseのすごいところですね〜

terminal
$ npm run build
terminal
$ firebase deploy

image.png

本番環境でも無事動きました!

本動画の感想

とても分かりやすい教材でした!

なぜこれが無料で見れるのか?と思えるほどのクオリティだと感じました。

元々は自分の復習用に備忘録を書き溜めていたのですが、私と同じように「これからreactを勉強したい!」と志す方々に少しでも広まればいいなと思い、公開記事として投稿した次第です。

次は、「React+ReduxでEC2サイトを作る」という講座も始められているみたいですので、そちらもチェックしたいと思います!

参考URL

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

React-Grid-LayoutでサクッとDrag & Dropできるコンポーネントを作る

React-Grid-Layoutを使用して、Drag & Dropで移動でき、サイズの変更も可能なコンポーネントの実装を試してみます。
完成形は以下のようになります。

react-grid-test.gif

React-Grid-Layoutって?

React-Grid-Layout(以下RGL)はReact用のグリッドレイアウトです(まんま)。
上の画像のように、D&Dでコンポーネントの移動ができるようになります。
また、RGLのインストールにはreact-resizableというパッケージも含まれており、コンポーネントのサイズの変更も可能になります。

多分GitHubのREADME見れば実装するのも簡単だと思いますが、
React初めて一週間くらいなので、練習がてらまとめておきたいと思います。

GitHub

https://github.com/STRML/react-grid-layout

インストール

まずはパッケージをインストールします。

npm install react-grid-layout

インストール後、以下のCSSのインポートをどこかに追加しましょう。
今回はindex.jsでインポートしました。

import '../node_modules/react-grid-layout/css/styles.css';
import '../node_modules/react-grid-layout/css/styles.css';

実装

あとはコーディングしていくだけです。
今回は子コンポーネントを一つだけ用意して、propsだけ変えて何個か呼び出すような感じにしました。
先にコードを載せておきます。
React超初心者なので書き方間違ってるところとかあったらスミマセン。

(親)App.js
import React from 'react';
import GridLayout from 'react-grid-layout';
import SampleComponent from './SampleComponent';

class App extends React.Component {

  constructor(){
    super();
    this.state = {
      cols : 12,
      rowHeight : 30,
      width :window.parent.screen.width
    }

    this.layouts = [
      {i: 'a', x: 0, y: 0, w: 2, h: 4},
      {i: 'b', x: 2, y: 0, w: 4, h: 4},
      {i: 'c', x: 6, y: 0, w: 2, h: 8},
      {i: 'd', x: 0, y: 4, w: 5, h: 5},
      {i: 'e', x: 0, y: 9, w: 5, h: 2},
      {i: 'f', x: 6, y: 11, w: 2, h: 2},
    ];
  }

  render(){
      return (
      <GridLayout className="layout" layout={this.layouts} cols={this.state.cols} rowHeight={this.state.rowHeight} width={this.state.width}>
        <div key="a" style={{border:"solid", backgroundColor:"#EEEEEE", textAlign:"center"}}><SampleComponent componentName="A" /></div>
        <div key="b" style={{border:"solid", backgroundColor:"#EEEEEE", textAlign:"center"}}><SampleComponent componentName="B" /></div>
        <div key="c" style={{border:"solid", backgroundColor:"#EEEEEE", textAlign:"center"}}><SampleComponent componentName="C" /></div>
        <div key="d" style={{border:"solid", backgroundColor:"#EEEEEE", textAlign:"center"}}><SampleComponent componentName="D" /></div>
        <div key="e" style={{border:"solid", backgroundColor:"#EEEEEE", textAlign:"center"}}><SampleComponent componentName="E" /></div>
        <div key="f" style={{border:"solid", backgroundColor:"#EEEEEE", textAlign:"center"}}><SampleComponent componentName="F" /></div>
      </GridLayout>
    );
  }
}

export default App;
(子)SampleComponent.js
import React from 'react';
import './App.css';

class SampleComponent extends React.Component{

    render(){
        console.log(this.props);
        return (
            <div>
                <h3>Component {this.props.componentName}</h3>
            </div>
        );
    }

}

export default SampleComponent;

親のGridLayout要素で囲んだ部分がグリッドレイアウトになります。
layout属性にあらかじめ子コンポーネントの位置を指定しておくようにします。
これでなんとなく機能を試すことができます。

しかしこれ、ページ表示時やリフレッシュ時にコンポーネントを移動させようとすると、
全コンポーネントが一瞬だけ浮き上がったような挙動をするんですよね…
自分のやり方がどこか間違ってるのかなんなのか…

ちょっとここは後で調べておかないとですね。

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

Reactで{}(curly bracket or 中括弧 or 波括弧)をimportで使う理由

結論

ES6のexportには

  1. Default Export
  2. Named Export

の2種類が有り、Named Exportには{ } を付けないとimportできない。

に追加して

  1. ファイルからオブジェクト全体をimport
  2. ファイルから特定のオブジェクトをimport

の2種類が有り、特定のオブジェクトをimportする際は{ }をつける。

内容

最近 reactを勉強し始めて、よく{ }(中括弧)でimportしたりしなかったりする状況が散見され、「どんな基準で付けたり付けなかったりするの。。。?」となった。

なので調べてみたらReactの話ではなく、まずES6の話だった。

通常、他のファイルで何か関数か変数を使いたい時は

export default App;

とexportするが、この場合はdefault exportされているので

import App from './path/to/App';

とimportできる。

一方でnamed exportは

export const A = 25;

とdefaultを使わずにexportされ、importする際には

import {A} from './path/to/A';

のようにする。

「ふむふむ、なるほどexportにdefault付けるか付けないかで違うのか~」となっている所に、ふと自分のコードを見ると、default付けているcomponentにも{ }を付けてimportをしているではないか。

「Why Japanese People!? 規則性ないじゃないか!」

更に調べると、どうやら

Curly Braces are used to import single(specific) property, whereas the word without braces is import entire object form that file.

これは例えば

import React, { Component } from 'react';

てな感じで、

特定のオブジェクトをimportしたいときはそのオブジェクトに{ }をつけてやるとのこと。

なるほどね、解決!
めでたしめでたし


参考文献
stackoverflow

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

Next.jsのDynamic Routingでqueryがundefinedになるときの対処

はじめに

Next.jsのDynamic Routingとは/posts/:idみたいなpathでルーティングしたい場合pages/postsディレクトリ配下に[id].tsxを置くことで実現できる機能です。
idの値を使用してデータをフェッチすると思いますが、1回目のレンダリングではidの値を取得できない問題にハマり悩んだので共有しておきます。

パラメータの取得

パスパラメータはクエリパラメータとしてページに送信され、他のクエリパラメータとマージされます。「はじめに」の例で言うと、idの値は以下のように取得できます。

import { useRouter } from 'next/router'

const Post = () => {
  const router = useRouter()
  const { pid } = router.query

  return <p>Post: {pid}</p>
}

export default Post

実際に使用する場合はこんな感じになるかと思います。

import { useRouter } from 'next/router'

const Post = () => {
  const ref = useRef()
  const router = useRouter()
  const id = router.query.id

  useEffect(() => {
    // ここでidの値を使用してデータをフェッチする 
  }, [ref]);

  // fetchしたデータを表示
  return <p>{hoge}</p>
}

export default Post

ここで最初のレンダリングrouter.query.idはundefinedとなります。

公式のドキュメントでは以下のように書かれています

自動静的最適化によって静的に最適化されたページは、ルートパラメータが提供されないままハイドレーションされます。

ハイドレーションの後、Next.jsはアプリケーションの更新をトリガーにして、クエリオブジェクトにルートパラメータを提供します。

なので、再レンダリング時にidを取得できるということになります。

ここでuseEffectでデータをfetchするときにidに値が入っていないのでデータを取得できないし、stateも変化しないので再レンダリングできないという問題が起きました。

解決方法

最初のレンダリングでは、router.asPathとrouter.routeは等しくなります。ですが、パスパラメータがqueryで利用可能になり、router.asPathに反映されたあとはパスは違うのでそれによって検知することができます。

import { useRouter } from 'next/router'

const Post = () => {
  const router = useRouter()
  const [id, setId] = useState<number>()

  // この部分を追加
  useEffect(() => {
    // idがqueryで利用可能になったら処理される
    if (router.asPath !== router.route) {
      setId(Number(router.query.id));
    }
  }, [router]);

  // idが取得されてセットされたら処理される
  useEffect(() => {
    if (id) {
      // ここでidの値を使用してデータをフェッチする 
    }
  }, [id]);

  // fetchしたデータを表示
  return <p>{hoge}</p>
}

export default Post

まとめ

  • Next.jsのDynamic Routingは最初のレンダリングでパスパラメータを取得できない
  • router.asPathを使用して処理することでパスパラメータがqueryで利用可能になったときのイベントを発火させることができる

もし、もっと良い実装方法があればぜひコメントで教えてください

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

Next.jsのDynamic Routingでパスパラメータを取得したときにundefinedになるときの対処

はじめに

Next.jsのDynamic Routingとは/posts/:idみたいなpathでルーティングしたい場合pages/postsディレクトリ配下に[id].tsxを置くことで実現できる機能です。
idの値を使用してデータをフェッチすると思いますが、1回目のレンダリングではidの値を取得できない問題にハマり悩んだので共有しておきます。

パラメータの取得

パスパラメータはクエリパラメータとしてページに送信され、他のクエリパラメータとマージされます。「はじめに」の例で言うと、idの値は以下のように取得できます。

import { useRouter } from 'next/router'

const Post = () => {
  const router = useRouter()
  const { pid } = router.query

  return <p>Post: {pid}</p>
}

export default Post

実際に使用する場合はこんな感じになるかと思います。

import { useRouter } from 'next/router'

const Post = () => {
  const ref = useRef()
  const router = useRouter()
  const id = router.query.id

  useEffect(() => {
    // ここでidの値を使用してデータをフェッチする 
  }, [ref]);

  // fetchしたデータを表示
  return <p>{hoge}</p>
}

export default Post

ここで最初のレンダリングrouter.query.idはundefinedとなります。

公式のドキュメントでは以下のように書かれています

自動静的最適化によって静的に最適化されたページは、ルートパラメータが提供されないままハイドレーションされます。

ハイドレーションの後、Next.jsはアプリケーションの更新をトリガーにして、クエリオブジェクトにルートパラメータを提供します。

なので、再レンダリング時にidを取得できるということになります。

ここでuseEffectでデータをfetchするときにidに値が入っていないのでデータを取得できないし、stateも変化しないので再レンダリングできないという問題が起きました。

解決方法

最初のレンダリングでは、router.asPathとrouter.routeは等しくなります。ですが、パスパラメータがqueryで利用可能になり、router.asPathに反映されたあとはパスは違うのでそれによって検知することができます。

import { useRouter } from 'next/router'

const Post = () => {
  const router = useRouter()
  const [id, setId] = useState<number>()

  // この部分を追加
  useEffect(() => {
    // idがqueryで利用可能になったら処理される
    if (router.asPath !== router.route) {
      setId(Number(router.query.id));
    }
  }, [router]);

  // idが取得されてセットされたら処理される
  useEffect(() => {
    if (id) {
      // ここでidの値を使用してデータをフェッチする 
    }
  }, [id]);

  // fetchしたデータを表示
  return <p>{hoge}</p>
}

export default Post

まとめ

  • Next.jsのDynamic Routingは最初のレンダリングでパスパラメータを取得できない
  • router.asPathを使用して処理することでパスパラメータがqueryで利用可能になったときのイベントを発火させることができる

もし、もっと良い実装方法があればぜひコメントで教えてください

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

create-react-app Lint構築よくばりパック(ESLint + TypeScript + airbnb + Prettier + lint-staged & husky + VSCode拡張)

create-react-app はデフォルトでESLintおよび独自ルールが組み込まれており、何もしなくてもLintの恩恵を受けられる。しかし、そうであってもカスタマイズしたくなるのがエンジニアの悲しき性。多くの人がカスタマイズに手をだすことだろう。

本記事ではcreate-react-app 環境におけるLint構築で話題によく上がる、「ESLint + TypeScript + airbnb + Prettier + lint-staged & husky + VSCode拡張」の全てを一気に導入するための手順を記載する。

2020年5月現在でLint環境としてまず導入したい全てのものを、可能な限りその出典(主に各パッケージの公式ドキュメント)も含めて抑えてあるつもりだ。またチュートリアル形式で全手順を記載しているため初心者にも安心。ぜひ役立てていただきたい。
(といいつつそもそものモチベーションは自分用メモ)

はじめに

導入として本記事の背景にあたる部分を説明する。実は「create-react-app におけるLint構築のカスタマイズ」は割とカオスで、真面目に考えると結構迷う部分もある。その中で紹介する手順がどういった位置付けになるかを理解いただければ、以降の理解がより深まるはず。

そんな話はいいから具体的な手順をまず教えてくれ!という方は、
本編: Lint環境構築 まで飛ばしてもらってもOKだ。

最初に押さえておいて欲しいこと

「create-react-app におけるLint構築のカスタマイズ」を行うにあたり、
最初にどうしても押さえておいて欲しいことがある。

それは「create-react-appは公式にはLintのカスタマイズに対応していない」ということだ。
より正確には「デフォルトのLint機能はカスタマイズも除去もできない1

詳しい経緯はかなり込み入った内容で本題から大きく外れるので省くが、知りたい方は以下のgithub issue等を参照いただきたい。
https://github.com/facebook/create-react-app/issues/3886

ともかく、基本的にyarn start時やブラウザのコンソールで表示されるLintの結果に関しては、こちらで手を加えることはできないと考えた方が良い。

カスタマイズの方針

その上で取りうる方針は基本的に以下の3つになる。

  1. 公式に従う
  2. デフォルトのLintとは別ラインでESLint環境を構築する。
    (デフォルト&カスタマイズの2つのLint環境が両立している状態)
  3. ejectする

"1." を採用するのが一番楽で手堅い。しかしそれで良い人はこの記事を読まないだろう。
"3." は沼なのでオススメしない。twitterあたりで「cra eject」あたりで検索するとそのことが理解できる(craはcreate-react-appの略称)。

本記事では"2."を採用する。
実際「create-react-app におけるLint構築のカスタマイズ」に関して調べると、多くは "2."を採用しているようだ。この場合「コンソールやビルド時のlintログがカスタマイズのLint内容と一致しない」というデメリットがあるが、Prettierやlint-stagedを用いることで、その影響を実運用上ほぼゼロに出来る。

いずれにせよ本記事の内容は、その多くがcreate-react-app公式推奨のやり方ではないという点だけは留意いただきたい。それでも他の情報を見る限り、このやり方はスタンダードに近いだろう。

本編: Lint環境構築

以下create-react-appによるアプリの作成から、

  • ESLint(TypeScript, airbnb)
  • Prettier
  • lint-staged & husky
  • VSCode拡張

を導入するための手順をチュートリアル形式で説明する。

これら全て行うことで、
「Lintの指摘を全てクリアしない限り、git commitが出来ない」
という素晴らしい環境が実現できる。

Lintを軽視する愚か者も、ここまですれば軽率な行動を取ることはないだろう。
(それってもしかして私のこ・・・ うっ、頭が…!)

以降は手順を端的に記載していく。ぜひ手を動かして試してみて欲しい。

アプリの作成

まずはじめにcreate-react-appでReactアプリを作成する。--typescriptを忘れないこと。

$ npx create-react-app react-lint-env --typescript

ESLintの設定

Lint環境のメインであるESLintの設定を行う。
相応に設定内容も多いが、記載の通りにやってもらえれば問題ないはずだ。
なお本手順ではairbnbを採用しているが、それは厳しすぎるという人のためにairbnbを使わない場合の手順も記載してある。必要な人は参照していただきたい。

eslint --initの実行

はじめにeslint --initを実行し、.eslintrc.json(eslintの設定ファイル)を作成する。
ESLint自体はcreate-react-appの段階でインストール済みなので、yarn add等する必要はない。
(yarn eslint ...で実行可能)

eslint --initでは対話式で設定内容を聞かれるが、それも含めて以下を参考にして欲しい。

参考サイト:
https://eslint.org/docs/user-guide/getting-started#installation-and-usage

$ yarn eslint --init
yarn run v1.22.4
react-lint-env/node_modules/.bin/eslint --init
? How would you like to use ESLint? # -> To check syntax, find problems, and enforce code style
? What type of modules does your project use? # -> JavaScript modules (import/export)
? Which framework does your project use? # -> React
? Does your project use TypeScript? # -> Yes
? Where does your code run? # -> Browser
? How would you like to define a style for your project? # -> Use a popular style guide
? Which style guide do you want to follow? # -> Airbnb: https://github.com/airbnb/javascript
? What format do you want your config file to be in? # -> JSON (または好きなものを選ぶ)
? Would you like to install them now with npm? # -> No (yarnを使う場合はNo必須)

なおairbnbを使用しない場合は、最初の質問で以下の選択をする。

To check syntax and find problems # -> "To check syntax and find problems" または "To check syntax only"

必要パッケージのインストール

.eslintrc.jsonの設定内容に応じて必要になるパッケージをインストールする。

yarn add -D eslint-plugin-react @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint-config-airbnb eslint-plugin-jsx-a11y eslint-plugin-import

各パッケージの概要は以下の通り。

  • eslint-plugin-react... ESLint公式推奨のReactルール
  • @typescript-eslint/parser & @typescript-eslint/eslint-plugin... ESLintをTypeScriptに対応させるためのツール
  • eslint-config-airbnb... airbnbのESLintルール
  • eslint-plugin-jsx-a11y & eslint-plugin-import... eslint-config-airbnbの依存パッケージ

参考:
なし (これらのパッケージは、インストールしていない状態でyarn eslint .を実行することで(エラー表示と共に)必要なことが示される)

airbnbを使用しない場合は、以下のコマンドを実行する

yarn add -D eslint-plugin-react @typescript-eslint/parser @typescript-eslint/eslint-plugin

"plugin:@typescript-eslint/recommended"の追加

前項で追加したパッケージのうち、typescript-eslint関連(@typescript-eslint/parser @typescript-eslint/eslint-plugin)については、.eslintrc.jsonへの設定追加が必要

参考:
https://github.com/typescript-eslint/typescript-eslint/blob/master/docs/getting-started/linting/README.md#configuration

diff --git a/.eslintrc.json b/.eslintrc.json
index 04f1ce9..19f04da 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -5,7 +5,8 @@
   },
   "extends": [
     "plugin:react/recommended",
-    "airbnb"
+    "airbnb",
+    "plugin:@typescript-eslint/recommended"
   ],
   "globals": {
     "Atomics": "readonly",

eslintへの設定追加(任意)

現状の設定だと開発時に不都合が生じるため、.eslintrc.jsonに以下の設定追加を推奨。
この点に関しては公式ではなく、参考サイト様の情報頼り。
(ありがとうございます)

参考:
https://ginpen.com/2019/08/06/eslint-for-react-in-typescript/

diff --git a/.eslintrc.json b/.eslintrc.json
index 4eebfbe..5461c90 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -22,5 +22,21 @@
     "sourceType": "module"
   },
   "plugins": ["react", "@typescript-eslint"],
-  "rules": {}
+  "settings": {
+    "import/resolver": {
+      "node": {
+        "extensions": [".js", ".jsx", ".ts", ".tsx"]
+      }
+    }
+  },
+  "rules": {
+    "@typescript-eslint/indent": ["error", 2],
+    "@typescript-eslint/prefer-interface": "off",
+    "react/jsx-filename-extension": [
+      "error",
+      { "extensions": [".jsx", ".tsx"] }
+    ],
+    "react/prop-types": "off",
+    "spaced-comment": ["error", "always", { "markers": ["/ <reference"] }]
+  }
 }

eslintの動作確認

ここまででeslintの設定は完了になる。そこでESLint単体で正常に動作しているか確認する。

yarn eslint . --ext .tsx --ext .ts
yarn run v1.22.4
$ /react-lint-env/node_modules/.bin/eslint . --ext .tsx --ext .ts

react-lint-env/src/App.test.tsx
  3:17  error  Unable to resolve path to module './App'        import/no-unresolved
  3:17  error  Missing file extension for "./App"              import/extensions
  5:1   error  'test' is not defined                           no-undef
  6:32  error  JSX not allowed in files with extension '.tsx'  react/jsx-filename-extension
  8:3   error  'expect' is not defined                         no-undef
  # (中略)
  145:14  error    Expected parentheses around arrow function argument          arrow-parens
  146:9   warning  Unexpected console statement                                 no-console

✖ 42 problems (36 errors, 6 warnings)
  22 errors and 0 warnings potentially fixable with the `--fix` option.

error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

多数エラーが出てくるが、Eslintの動作確認としてはこの結果でOK。
(create-react-appで生成されるコードはairbnb基準だとかなり指摘が入る)

念の為 yarn start(Reactアプリのビルド)が正常に動作しているかも確認する。

$ yarn start
Compiled successfully!

You can now view react-lint-env in the browser.

  Local:            http://localhost:3000
  On Your Network:  http://192.168.0.51:3000

Note that the development build is not optimized.
To create a production build, use yarn build.

Prettierの設定

次にformatterとしてPrettierを導入する。ルールに合致しないコードを自動で修正してくれる。
(特にVSCodeの拡張と組み合わせることでその真価を発揮する)
Prettierはあまりカスタマイズしない運用を推奨しており、設定内容もそれほど多くない。

関連パッケージのインストール

必要なパッケージをインストールする。

参考サイト:
https://prettier.io/docs/en/integrating-with-linters.html#recommended-configuration

yarn add -D prettier eslint-plugin-prettier eslint-config-prettier

各パッケージの概要は以下の通り。

prettier... prettier本体
eslint-config-prettier... ESLintでformatterとしてPrettierを使うようにしてくれる
eslint-config-prettier... ESLintとPrettierでバッティングする箇所をよしなにしてくれる

.eslintrc.jsonへの設定追加

eslint-plugin-prettier, eslint-config-prettier の両方を使用する設定。
この設定を行うことで、yarn eslint --fix実行時にPrettierを利用したコードフォーマットが行われるようになる。

参考: https://prettier.io/docs/en/integrating-with-linters.html#recommended-configuration

diff --git a/.eslintrc.json b/.eslintrc.json
index 19f04da..3bac18e 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -6,7 +6,8 @@
   "extends": [
     "plugin:react/recommended",
     "airbnb",
-    "plugin:@typescript-eslint/recommended"
+    "plugin:@typescript-eslint/recommended",
+    "plugin:prettier/recommended"
   ],
   "globals": {
     "Atomics": "readonly",

.prettierrc.jsonの作成(任意)

prettierのカスタマイズ。やらなくても問題はない。
以下の設定は私の趣味(一応airbnbに合わせている)

参考:
https://prettier.io/docs/en/configuration.html#basic-configuration

$ touch .prettierrc.json
diff --git a/.prettierrc.json b/.prettierrc.json
new file mode 100644
index 0000000..fd496a8
--- /dev/null
+++ b/.prettierrc.json
@@ -0,0 +1,4 @@
+{
+  "singleQuote": true,
+  "semi": false
+}

Prettier動作確認

Prettierの設定は以上。動作確認を行う。eslint コマンドに”—fix”をつける

$ yarn eslint . --ext .tsx --ext .ts --fix
yarn run v1.22.4
$ react-lint-env/node_modules/.bin/eslint . --ext .tsx --ext .ts

react-lint-env/src/App.test.tsx
  1:26  error  Delete `;`                                      prettier/prettier
  2:48  error  Delete `;`                                      prettier/prettier
  3:17  error  Unable to resolve path to module './App'        import/no-unresolved
# (中略)
@typescript-eslint/explicit-module-boundary-types
  143:9   warning  Unexpected console statement                                 no-console

✖ 28 problems (19 errors, 9 warnings)
  7 errors and 0 warnings potentially fixable with the `--fix` option.

$ git status
On branch master
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        modified:   src/App.test.tsx
        modified:   src/App.tsx
        modified:   src/index.tsx
        modified:   src/react-app-env.d.ts
        modified:   src/serviceWorker.ts
        modified:   src/setupTests.ts

no changes added to commit (use "git add" and/or "git commit -a")

Prettierによる指摘の確認と、それに伴うコードの修正が実施されればOK。
(create-react-appで生成されたコードであっても、Prettierによってかなり修正を加えられているはず)

lint-staged & haskyの設定

lint-staged & haskyの設定を行うことで、git commit時にLintチェックし、パスしない場合commitを却下することが可能になる。

パッケージのインストール

$ yarn add -D lint-staged husky

各パッケージの概要は以下の通り。

lint-staged... gitのstage状態にあるファイルを対象にLintを実行する
husky... Gitフック(特にpre-commit)をハンドリングする

package.jsonへの設定追加

参考:
https://github.com/okonet/lint-staged#examples

diff --git a/package.json b/package.json
index 80bb375..4c4ccc2 100644
--- a/package.json
+++ b/package.json
@@ -49,5 +49,13 @@
     "husky": "^4.2.5",
     "lint-staged": "^10.2.7",
     "prettier": "^2.0.5"
+  },
+  "husky": {
+    "hooks": {
+      "pre-commit": "lint-staged"
+    }
+  },
+  "lint-staged": {
+    "*.{ts,tsx}": "eslint --max-warnings=0 --ext .tsx --ext .ts --fix"
   }
 }

eslintのコマンドに、--max-warning=0というオプションを付けている点に注意。
これが無いとwarningだけ残っている場合にcommitがパスしてしまう。

参考:
https://qiita.com/kondei/items/fbad4b746836383c6481

lint-staged & husky 動作確認

適当なtsxファイルにlintに引っかかる修正を加えた上で、git commitを行う。
lintが実行され、git commitの失敗を確認できればOK。

diff --git a/src/App.tsx b/src/App.tsx
index a53698a..cf0d16b 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -3,6 +3,8 @@ import logo from './logo.svg';
 import './App.css';

 function App() {
+  const test = 1
+
   return (
     <div className="App">
       <header className="App-header">
$ git commit -m "add variable"
husky > pre-commit (node v12.7.0)
✔ Preparing...
⚠ Running tasks...
  ❯ Running tasks for *.{ts,tsx}
    ✖ eslint --max-warnings=0 --ext .tsx --ext .ts --fix [FAILED]
↓ Skipped because of errors from tasks. [SKIPPED]
✔ Reverting to original state because of errors...
✔ Cleaning up... 

✖ eslint --max-warnings=0 --ext .tsx --ext .ts --fix:

react-lint-env/src/App.tsx
   5:1   warning  Missing return type on function                          @typescript-eslint/explicit-module-boundary-types
   6:9   warning  'test' is assigned a value but never used                @typescript-eslint/no-unused-vars
  13:16  error    `code` must be placed on a new line                      react/jsx-one-expression-per-line
  13:40  error    Replace `{'·'}` with `·`                               prettier/prettier
  14:1   error    Expected indentation of 10 space characters but found 0  react/jsx-indent
  14:1   error    Expected indentation of 10 spaces but found 0            @typescript-eslint/indent
  14:6   error    Expected indentation of 10 space characters but found 0  react/jsx-indent
  16:1   error    Insert `········`                                        prettier/prettier
  16:1   error    Expected closing tag to match indentation of opening     react/jsx-closing-tag-location
  16:1   error    Expected indentation of 8 space characters but found 0   react/jsx-indent
  16:1   error    Expected indentation of 8 spaces but found 0             @typescript-eslint/indent

✖ 11 problems (9 errors, 2 warnings)
  9 errors and 0 warnings potentially fixable with the `--fix` option.

husky > pre-commit hook failed (add --no-verify to bypass)

VSCode拡張機能

ESLint

以下をVSCodeにインストール。VSCode上でリアルタイムにLintのエラーを表示してくれる。
https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint

Prettier

以下をVSCodeにインストール。ファイル保存のタイミングでPrettierの実行(= コードフォーマット)が可能となる。
https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode

以下の設定も必要。
Editor: Format On Save を有効化
スクリーンショット 2020-05-25 23.38.14.png

さいごに

この記事書くの大変だった。疲れた。


  1. 公式ドキュメントには「Extending the ESLint config」に関する記載はあるが、これはExperimentalなものであり、また利用した記事もあまり見当たらないため、本記事では対象外とする。 

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