20200923のReactに関する記事は13件です。

local環境でReactをhttpsで立てる(WSL)

はじめに

wslのlocal環境でreactをhttpsで立てる際、少し特殊な設定が必要だったので備忘録として作業手順を残しておきます。

SSL証明書の発行まで

こちらの記事を参考に作業を進めます。(ルート証明書のインストールまで)
https://qiita.com/recordare/items/d51f50dc634187e20538

httpsでReactを立ち上げる

mkcert -CAROOTに移動し、mkcert localhostを実行する

次にReactのプロジェクトディレクトリに移動して、以下のコマンドを実行
HTTPS=true SSL_CRT_FILE=$(mkcert -CAROOT)/localhost.pem SSL_KEY_FILE=$(mkcert -CAROOT)/localhost-key.pem npm start

(公式: https://create-react-app.dev/docs/using-https-in-development/)

これで警告なくhttpsでlocalhost:3000にアクセスできる!!

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

【ReactNative × Redux】本格的なTodoリストを作成しました

ReactNativeとReduxの勉強のアウトプットとして多機能なTodoリストを作成しました。
ReactNativeとReduxの組み合わせの資料は意外に少なく、うまく動作しないものが多かった印象です。そのため、ReactとReduxの少し古めの記事をReactNativeに置き換えてなんとか頑張ってみました。
そもそもJavaScriptなどの基礎知識が曖昧だったということもあってクオリティは低いかと思います。
制作期間はReactNativeに触れてからちょうど1週間です。(Reactを少しだけ勉強してたくらいです)

ソースコードはこちらになります。
https://github.com/s-amano/react-native-todo-list

要件、機能、使用した技術など

  • TODOのデータ型

    • id // 識別するための番号
    • completed // チェックしているかどうか
    • title // TODOのタイトル
    • description // TODOの詳細
    • createdAt // 作成日時
  • 使用した技術

    • ReactNative
    • Redux
  • 要件

    • 追加ができる
    • 編集ができる
    • 削除ができる
    • 作成日時のソート(昇順・降順)ができる
    • リアルタイム検索ができる
    • Doneチェックがつけられる(toggle機能)
    • Doneのチェックがついたもののみ表示できる
    • React Navigationを使った画面遷移
    • データの永続化

実際に作ったアプリ

スクショ
操作や機能の補足としては、
ナビゲーションの勉強のためtodoの追加は別ページにしており、
Todoの詳細ページ→編集ページの遷移ができます。
また、検索は1文字ごとに検索してくれます。

詰まった部分、大変だった部分

正直なんども詰まりました。web上に情報があんまなかったので(削除機能でさえなかった)、逆に自分の頭の中で実装方法を考えて手を動かしてトライアンドエラーを繰り返すといういい経験になりました。
その中でも概念的に詰まった部分、エラーで詰まった部分をまとめてみたいと思います。

  • 謎すぎるバグ→正体これでした→https://qiita.com/tenshinhan_yamucha/items/6923c78fb53024c71a8b

  • stateとはViewに表示されているデータやUIの状態などのアプリケーションが保持している情報のこと
     →ソートの条件や実際のtodoなど

  • 全体のデータフローとしては、ActionをStoreへdispatch(送信)すると、Storeのstateが変更されるという感じ

  • Actionはアクション(何が起きたのか)とそれに付随する情報を持つオブジェクト

  • store内ではactionをreducerが受け取りstoreに対して新たなstateオブジェクトを作成

また、

  • Reducerに働いてもらうためには、ActionをStoreにDispatchする。
  • Stateを取得するには、StoreからgetStateする。
    • React側でReduxを使ったり、stateの情報を取ってくるのには、上記2つが必要で、それをcomponent側(reactのview側)で意識せずに使いたいので、containerで、以下を定義し、reactとreduxをconnectする
    • mapStateToPropsは、Store.getState()のような役割をして、ComponentのpropsにStateの中身を詰め込んでくれる
    • mapDispatchToPropsは、ActionCreatorをラップした、actionをStoreにDispatchしてくれる関数をpropsに詰め込んでくれる
  • 上記をcontainerで定義すれば、componentでpropsとして受け取り描画したり、関数を直接使えるのでリファクタリングしやすいし可読性が高まる

アプリの課題、追加でやりたいこと

上記で一応アプリの紹介は終わりですが、「もしこの先も勉強のためにこのアプリの作成を続けるとしたらなにをするのか=このアプリの課題」について箇条書きで述べたいと思います。

  • テスト導入
  • Typescriptで書いてみたい
  • redux-thunkなどによる非同期処理→前に作ったDjangoのAPIを使ってもいいかも
  • フォームにバリデーションをつけたい→propTypesというものでできそう。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

React+TS+Firestoreで簡単なCRUDアプリを実装する手順まとめ

create-react-app + Firestore を使って、簡易的なCRUDアプリを実装する手順を解説します。極力最小構成にしているので、これから理解しようとしている方にはちょうど良いと思います!

ReactとFirestoreについてなんとなく理解している方が対象です。

ソースコード

コミット分けたので、ログの差分をご活用ください!

https://github.com/kazztech/react-ts-firebase/commits/master

フロントの実装

React(TS)プロジェクトを作成

nodejsの環境は構築済みとします。

# npx create-react-app <プロジェクト名> [オプション]
npx create-react-app react-ts-firebase --typescript

開発環境で実行

cd <プロジェクト名>
npm run start

http://localhost:3000 へアクセス

スクリーンショット 2020-09-23 14.35.34.png

不要なCSSなどのファイルを削除

./
├── node_modules
│   └── ...
├── package.json
├── public
│   ├── favicon.ico
│   ├── index.html
│   ├── logo192.png
│   ├── logo512.png
│   ├── manifest.json
│   └── robots.txt
├── src
│   ├── App.tsx
│   ├── index.tsx
│   ├── react-app-env.d.ts
│   ├── serviceWorker.ts
│   └── setupTests.ts
├── tsconfig.json
└── yarn.lock

一旦余計なソースを削除

index.tsx
import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
import * as serviceWorker from "./serviceWorker";

ReactDOM.render(<App />, document.getElementById("root"));

serviceWorker.unregister();
App.tsx
import React from "react";

function App() {
  return <div></div>;
}

export default App;

まずはダミーのデータを用いて描画処理

今回はAppコンポーネントに直書きします。

App.tsx
import React, { useState } from "react";

type User = {
  id: string;
  name: string;
  height: number;
};

function App() {
  const [users, setUsers] = useState<User[]>([
    { id: "ID1", name: "ゾンビ", height: 195 },
    { id: "ID2", name: "スケルトン", height: 199 },
    { id: "ID3", name: "クリーパー", height: 170 },
  ]);
  return (
    <div>
      <h2>Users</h2>
      {users.map((user) => (
        <div key={user.id}>
          {user.name} ({user.height}cm)
        </div>
      ))}
    </div>
  );
}

export default App;

Firestoreの登録

https://console.firebase.google.com/

登録を進めたら、

プロジェクトを追加 -> プロジェクト名を入力 -> その他入力 -> 完了

サイドバーから「Cloud Firestore」を選択 -> データベース作成 -> テストモードをチェック -> Cloud Firestore のロケーション「asia-northeast3」を選択 -> 有効にする -> 完了

データベースが作成できたら、任意のデータを挿入

スクリーンショット 2020-09-23 14.17.58.png

コレクションはテーブルみたいなもので、ドキュメントはレコードみたいなものです。上記では、ドキュメントIDにランダム文字列を使用しています。

Firestoreとアプリケーションの連携

まず、Reactアプリケーションに以下のSDKを導入

npm install firebase

次に ./srcfirebase.ts を作成し、以下の内容を記述

firebase.ts
import firebase from "firebase/app";
import "firebase/firestore";
import { firebaseConfig } from "./firebaseConfig";

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

firebaseConfig.ts の値は、webにてアプリを追加したのちに設定/全般ページの下の方で取得できます。

スクリーンショット 2020-09-23 14.50.24.png

スクリーンショット 2020-09-23 15.00.39.png

firebaseConfig.ts
export const firebaseConfig = {
  apiKey: "",
  authDomain: "",
  databaseURL: "",
  projectId: "",
  storageBucket: "",
  messagingSenderId: "",
  appId: "",
};

firebaseConfig.ts は認証情報が含まれるので、公開してはいけません。.gitignore に含めておきましょう。

.gitignore
...

# firebase
firebaseConfig.ts

一覧表示を実装

まず、連携が無事に出来ているかを確認

App.tsx
import React, { useState } from "react";

type User = {
  id: string;
  name: string;
  height: number;
};

function App() {
  const [users, setUsers] = useState<User[]>([]);

  // === 追記分 ===
  useEffect(() => {
    // 取得結果をコンソールに出力
    const usersRef = db.collection("users");
    usersRef.get().then((snapshot) => {
      snapshot.forEach((doc) => {
        console.log(doc.id, doc.data())
      });
    });
  }, []);
  // =============

  return (
    <div>
      <h2>Users</h2>
      {users.map((user) => (
        <div key={user.id}>
          {user.name} ({user.height}cm)
        </div>
      ))}
    </div>
  );
}

export default App;

ページを表示し、連携がうまくいっていたらデータがそのまま表示されるはずです。

スクリーンショット 2020-09-23 19.45.31.png

取得したデータを setUsers() を利用して state に代入

App.tsx
import React, { useEffect, useState } from "react";
import { db } from "./firebase";

type User = {
  id: string;
  name: string;
  height: number;
};

function App() {
  const [users, setUsers] = useState<User[]>([]);

  // === 追記分 ===
  const fetchUsersData = () => {
    const usersRef = db.collection("users");
    usersRef.get().then((snapshot) => {
      const newUsers: any[] = [];
      snapshot.forEach((doc) => {
        newUsers.push({
          id: doc.id,
          ...doc.data(),
        });
      });
      setUsers(newUsers);
    });
  };

  useEffect(() => {
    fetchUsersData();
  }, []);
  // =============

  return (
    <div>
      <h2>Users</h2>
      {users.map((user) => (
        <div key={user.id}>
          {user.name} ({user.height}cm)
        </div>
      ))}
    </div>
  );
}

export default App;

スクリーンショット 2020-09-23 20.04.32.png

追加、削除、更新の実装

削除処理

スクリーンショット 2020-09-23 20.28.59.png

削除したタイミングでデータを取得し直すように実装しました。それと、一応のエラー処理も実装してみました。

App.tsx
function App() {

  ...

  // 追加
  const handleDelete = (id: string) => {
    if (window.confirm("削除してもよろしいですか?")) {
      db.collection("users")
        .doc(id)
        .delete()
        .then(() => {
          fetchUsersData();
          alert("削除しました");
        })
        .catch(() => {
          alert("失敗しました");
        });
    }
  };

  ...

  return (
    <div>
      <h2>Users</h2>
      <table>
        <tbody>
          {users.map((user) => (
            <tr key={user.id}>
              <td>{user.name}</td>
              <td>{user.height}cm</td>
              <td>
                <button onClick={() => handleDelete(user.id)}>削除</button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

...

追加処理

スクリーンショット 2020-09-23 21.23.07.png

App.tsx
function App() {

  ...

  // 追加
  const [addUserName, setAddUserName] = useState<string>("");
  const [addUserHeight, setAddUserHeight] = useState<number>(200);

  ...

  // 追加
  const handleAdd = () => {
    if (window.confirm("追加してもよろしいですか?")) {
      db.collection("users")
        .add({
          name: addUserName,
          height: addUserHeight,
        })
        .then(() => {
          fetchUsersData();
          setAddUserHeight(200);
          setAddUserName("");
          alert("追加しました");
        })
        .catch(() => {
          alert("失敗しました");
        });
    }
  };

  ...

  return (
    <div>
      <h2>Users</h2>
      {/* === 追加分 === */}
      <div>
        <label>
          NAME:{" "}
          <input
            type="text"
            value={addUserName}
            onChange={(event) => setAddUserName(event.target.value)}
          />
        </label>
        <label>
          HEIGHT:{" "}
          <input
            type="number"
            value={addUserHeight}
            onChange={(event) => setAddUserHeight(event.target.valueAsNumber)}
          />
        </label>
        <button onClick={() => handleAdd()}>追加</button>
      </div>
      {/* ============= */}
      <table>
        <tbody>
          {users.map((user) => (
            <tr key={user.id}>
              <td>{user.name}</td>
              <td>{user.height}cm</td>
              <td>
                <button onClick={() => handleDelete(user.id)}>削除</button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

...

更新処理

実装は省略しますが、set メソッドで実装できます。

db.collection('cities').doc('new-city-id').set(data);

最後に

短期間の開発ではこれでいいかもしれませんが、そうでない場合は共通化・モジュール化などしましょう!

リンク

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

React + TypeScript + vte.cxで簡単なWebアプリを作ってみた③ 条件検索機能編

はじめに

今まで作ったアプリケーションに今回は登録されているデータを条件によって検索できる機能を実装していきました。

今回作ったアプリケーション

image.png

一つの画面の中に条件フォームを作り、絞りこみボタンを押すとその条件に合致した情報を画面上に表示する機能です。

例えば以下の画像では「好きな住居形態」という項目で「森」を選んだ結果、合致するゴリラとhatakeyamaが画面に表示されました。

スクリーンショット 2020-09-23 9.40.19.png

前回のページネーションがちゃんと作動しているのでページネーション番号もちゃんと表示されています。
また絞る条件は一項目ではなく複数項目選ぶことができます。

今回使用するコンポーネント

条件検索のロジックを持ったUserInfoFilterコンポーネントと条件検索で帰ってきた値を格納するUserInfoコンポーネントを今回使っていきます。

UserInfoFilter.tsx(条件検索するコンポーネント)

UserInfoFilter.tsx
import * as React from 'react'
import { useContext } from 'react'
import { Store } from './App'
import axios from 'axios'
import Form from './Form'
import { displayPage } from './UserInfo'

const UserInfoFilter = (props: any) => {
    const { dispatch } = useContext(Store)

    //Formコンポーネントが持っているusers情報をコールバック関数で受け取っている
    const searchData = async (name: string, gender: string, age: number, address: string, password: string, email: string, postNumber: string, likeResidenceType: string, position: string, language: string) => {
        const nameParameter = name ? 'users.name=' + name + '&' : ''
        const genderParameter = gender ? 'users.gender=' + gender + '&' : ''
        const ageParameter = age ? 'users.age=' + age + '&' : ''
        const addressParameter = address ? 'users.address=' + address + '&' : ''
        const passwordParameter = password ? 'users.password=' + password + '&' : ''
        const emailParameter = email ? 'users.email=' + email + '&' : ''
        const postNumberParameter = postNumber ? 'users.post_number=' + postNumber + '&' : ''
        const likeResidenceTypeParameter = likeResidenceType ? 'users.like_residence_type=' + likeResidenceType + '&' : ''
        const positionParameter = position ? 'users.position=' + position + '&' : ''
        const languageParameter = language ? 'users.language=' + language + '&' : ''

        // ajaxパラメータの中身
        const searchParams = nameParameter + genderParameter + ageParameter + addressParameter + passwordParameter + emailParameter + postNumberParameter + likeResidenceTypeParameter + positionParameter + languageParameter

        // 親に渡すparams
        const passParams = {
            name,
            gender,
            age,
            address,
            password,
            email,
            postNumber,
            likeResidenceType,
            position,
            language
        }
        try {
            dispatch({ type: 'SHOW_INDICATOR' })
            axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'
            // pageindexの作成
            await axios.get(
                '/d/users?' + searchParams + '_pagination=1,4&l=' + displayPage
            )
                .then(async (res) => {
                    //条件検索時の総取得件数
                    console.log(res.data.feed.subtitle)
                    props.click(passParams, res.data.feed.subtitle)
                })
                .then(() => {
                    dispatch({ type: 'HIDE_INDICATOR' })
                })
        } catch (e) {
            alert('error:' + e)
            dispatch({ type: 'HIDE_INDICATOR' })
        }
    }

    return (
        <>
            <h3>絞り込み検索</h3>
            <Form click={searchData} submitName={'絞り込む'} />
        </>
    )
}

export default UserInfoFilter

このコンポーネントの流れは

return (
        <>
            <h3>絞り込み検索</h3>
            <Form click={searchData} submitName={'絞り込む'} />
        </>
    )

フォームコンポーネントにpropsでsearchDataメソッドとFormコンポーネントの中のボタンに絞りこみという名前を渡しています。

ちなみにこの部分です。

スクリーンショット 2020-09-23 10.29.15.png

この絞りこみボタンを押すと、入力されたデータを親コンポーネント(UserInfo.tsx)に渡します。

入力された後にsearchDataメソッドが発火します。

UserInfoFilter.tsx
const searchData = async (name: string, gender: string, age: number, address: string, password: string, email: string, postNumber: string, likeResidenceType: string, position: string, language: string) => {
        const nameParameter = name ? 'users.name=' + name + '&' : ''
        const genderParameter = gender ? 'users.gender=' + gender + '&' : ''
        const ageParameter = age ? 'users.age=' + age + '&' : ''
        const addressParameter = address ? 'users.address=' + address + '&' : ''
        const passwordParameter = password ? 'users.password=' + password + '&' : ''
        const emailParameter = email ? 'users.email=' + email + '&' : ''
        const postNumberParameter = postNumber ? 'users.post_number=' + postNumber + '&' : ''
        const likeResidenceTypeParameter = likeResidenceType ? 'users.like_residence_type=' + likeResidenceType + '&' : ''
        const positionParameter = position ? 'users.position=' + position + '&' : ''
        const languageParameter = language ? 'users.language=' + language + '&' : ''

        // ajaxパラメータの中身
        const searchParams = nameParameter + genderParameter + ageParameter + addressParameter + passwordParameter + emailParameter + postNumberParameter + likeResidenceTypeParameter + positionParameter + languageParameter

        // 親に渡すparams
        const passParams = {
            name,
            gender,
            age,
            address,
            password,
            email,
            postNumber,
            likeResidenceType,
            position,
            language
        }
        try {
            dispatch({ type: 'SHOW_INDICATOR' })
            axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'
            // pageindexの作成
            await axios.get(
                '/d/users?' + searchParams + '_pagination=1,4&l=' + displayPage
            )
                .then(async (res) => {
                    //条件検索時の総取得件数
                    console.log(res.data.feed.subtitle)
                    props.click(passParams, res.data.feed.subtitle)
                })
                .then(() => {
                    dispatch({ type: 'HIDE_INDICATOR' })
                })
        } catch (e) {
            alert('error:' + e)
            dispatch({ type: 'HIDE_INDICATOR' })
        }
    }
const nameParameter = name ? 'users.name=' + name + '&' : ''

三項演算子でFormから渡ってきたデータ項目があればページインデックスを作成するための加工をします。なければ空文字を返します。

条件検索を使ったインデックスを作成するためにはajax通信で以下のパラメーターを使います。

'/d/{エンドポイント名}?条件&_pagination={開始ページ、終了ページ}&l={1ページの表示件数}'
// pageindexの作成
            await axios.get(
                '/d/users?' + searchParams + '_pagination=1,4&l=' + displayPage
            )
                .then(async (res) => {
                    //条件検索時の総取得件数
                    props.click(passParams, res.data.feed.subtitle)
                })

この部分ですね。
また、

UserInfoFilter.tsx
props.click(passParams,res.data.feed.subtitle)

の部分で親に項目のデータと総件数(res.data.feed.subtitleで総件数が帰ってきます。)を渡しています。

UserInfo.tsx(UserInfoFilterで貼ったインデックスをもとにデータをとってくるコンポーネント)

UserInfo.tsx
import * as React from 'react'
import { useState, useEffect, useContext, useRef } from 'react'
import axios from 'axios'
import UserList from './UserList'
import { Store } from './App'
import Pagination from './Pagination'
import UserInfoFilter from './UserInfoFilter'

interface SearchConditions {
    name?: string
    gender?: '' | '' | ''
    age?: number
    address?: string
    password?: string
    email?: string
    postNumber?: string
    likeResidenceType?: string
    position?: string
    language?: string
}

// 1ページに表示させる件数
export const displayPage = 5

const UserInfo = () => {
    // apiを叩いてgetしたデータをusersに格納する
    const [users, setUsers] = useState([])
    // 検索条件(UserInfoFilterから渡ってくる)
    const [searchConditions, setSearchConditions] = useState<SearchConditions>()
    // 総ページ数
    const [sumPageNumber, setSumPageNumber] = useState(0)

    // 現在見ているページネーション
    const [currentPage, setCurrentPage] = useState(1)

    const { dispatch } = useContext(Store)

    // コンポーネントマウント後に以下のページインデックスを作成する関数が実行される
    // 初期描画の実行
    useEffect(() => {
        getTotalFeedNumber()
        console.log('useEffect:getTotalFeedNumber')
    }, [])

    // 初期描画後
    // 最初にページインデックスを作成終了後、handlePaginateで1ページを指定している
    const mounted = useRef(false)
    useEffect(() => {
        if (mounted.current) {
            if (sumPageNumber === 0) {
                setUsers([])
                return
            }
            handlePaginate(1)
            console.log('useEffect:mounted.current=true')
        } else {
            mounted.current = true
            console.log('useEffect:mounted.current=false')
        }
    }, [sumPageNumber])

    //ページの取得処理
    let retryCount = 0
    // この処理をgetTotalFeedNumberを処理したときに実行したい

    //page番号を使ってAPIを叩く処理
    const handlePaginate = async (page: number) => {
        const nameParameter = searchConditions?.name ? 'users.name=' + searchConditions.name + '&' : ''
        const genderParameter = searchConditions?.gender ? 'users.gender=' + searchConditions.gender + '&' : ''
        const ageParameter = searchConditions?.age ? 'users.age=' + searchConditions.age + '&' : ''
        const addressParameter = searchConditions?.address ? 'users.address=' + searchConditions.address + '&' : ''
        const passwordParameter = searchConditions?.password ? 'users.password=' + searchConditions.password + '&' : ''
        const emailParameter = searchConditions?.email ? 'users.email=' + searchConditions.email + '&' : ''
        const postNumberParameter = searchConditions?.postNumber ? 'users.post_number=' + searchConditions.postNumber + '&' : ''
        const likeResidenceTypeParameter = searchConditions?.likeResidenceType ? 'users.like_residence_type=' + searchConditions.likeResidenceType + '&' : ''
        const positionParameter = searchConditions?.position ? 'users.position=' + searchConditions.position + '&' : ''
        const languageParameter = searchConditions?.language ? 'users.language=' + searchConditions.language + '&' : ''

        // リトライ回数
        const LIMIT_RETRY_COUNT = 10

        const searchParams = nameParameter + genderParameter + ageParameter + addressParameter + passwordParameter + emailParameter + postNumberParameter + likeResidenceTypeParameter + positionParameter + languageParameter

        try {
            console.log('handlePaginateが作動しました' + page)
            console.log(searchParams)
            dispatch({ type: 'SHOW_INDICATOR' })
            axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'
            if (searchConditions) {
                await axios.get(
                    '/d/users?' + searchParams + 'n=' + page + '&l=' + displayPage
                ).then((res) => {
                    if (res && res.data && res.data.length) {
                        setUsers(res.data)
                    }
                    setCurrentPage(page)
                }).then(() => {
                    retryCount = 0
                    dispatch({ type: 'HIDE_INDICATOR' })
                })
            } else {
                await axios.get(`/d/users?n=${page}&l=${displayPage}`).then((res) => {
                    if (res && res.data && res.data.length) {
                        setUsers(res.data)
                    }
                    setCurrentPage(page)
                }).then(() => {
                    retryCount = 0
                    dispatch({ type: 'HIDE_INDICATOR' })
                })
            }

        } catch (e) {
            if (e.response.data.feed.title === 'This process is still in progress. Please wait.') {
                retryCount++
                console.log(retryCount)
                if (retryCount < LIMIT_RETRY_COUNT) {
                    handlePaginate(page)
                } else {
                    dispatch({ type: 'HIDE_INDICATOR' })
                    alert('error:' + e)
                    alert('Process error')
                }
            }

            if (e.response.data.feed.title === 'Please make a pagination index in advance.') {
                retryCount++
                console.log(retryCount)
                if (retryCount < LIMIT_RETRY_COUNT) {
                    handlePaginate(page)
                } else {
                    dispatch({ type: 'HIDE_INDICATOR' })
                    alert('error:' + e)
                    alert('Not create pagination index')
                }
            }
        }
    }

    // paginationIndexを作成する処理
    const getTotalFeedNumber = async () => {
        try {
            dispatch({ type: 'SHOW_INDICATOR' })
            axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'

            await axios.get(`/d/users?_pagination=1,4&l=${displayPage}`).then((res) => {
                setSumPageNumber(res.data.feed.subtitle)
                console.log(res)
            }).then(() => {
                dispatch({ type: 'HIDE_INDICATOR' })
            })
        } catch (e) {
            dispatch({ type: 'HIDE_INDICATOR' })
            alert('error:' + e)
        }
    }

    const searchedPaginate = async (passedParams: SearchConditions, passedSumPageNumber: number) => {
        //子から渡される検索条件
        await setSearchConditions(passedParams)
        //子から渡される条件検索後のページ総件数
        await setSumPageNumber(passedSumPageNumber)
    }

    return (
        <>
            <UserInfoFilter click={searchedPaginate} />
            <h3>情報一覧</h3>
            <p>総件数:<span style={{ color: 'blue' }}>{sumPageNumber}</span>件</p>
            <p>現在<span style={{ color: 'blue' }}>{currentPage}</span>ページ目</p>
            <UserList info={users} />
            <Pagination sum={sumPageNumber} per={displayPage} onChange={e => handlePaginate(e.page)} />
            <button onClick={(e) => { e.preventDefault(), console.log(sumPageNumber) }}>sumPageNumberを調べる</button>
        </>
    )
}

export default UserInfo

UserInfoコンポーネントの子コンポーネントであるUserInfoFilter.tsxから渡される情報を引数として受け取り、setSearchConditionsで値を格納します。

UserInfo.tsx
const searchedPaginate = async (passedParams: SearchConditions, passedSumPageNumber: number) => {
        //子から渡される検索条件
        await setSearchConditions(passedParams)
        //子から渡される条件検索後のページ総件数
        await setSumPageNumber(passedSumPageNumber)
    }
UserInfo.tsx
// 検索条件(UserInfoFilterから渡ってくる)
    const [searchConditions, setSearchConditions] = useState<SearchConditions>(

このsearchConditionsというものがなんのために必要なのかというと、ページネーションをする際に必要になってきます。

また,setSumPageNumberすることによってSumPageNumberの値が変わり、SumPageNumberを監視しているuseEffectによってPaginateメソッドが作動します。

handlePaginateメソッドの中で

UserInfo.tsx
if (searchConditions) {
    await axios.get(
        '/d/users?' + searchParams + 'n=' + page + '&l=' + displayPage)
} else {
    await axios.get(`/d/users?n=${page}&l=${displayPage}`)
}

という部分があり、ページネーションをする際に毎回searchConditionsに値があれば条件検索をされたページネーションする流れになっています。
なければ通常通りの条件なしでのページネーションします。

まとめ

①UserInfoFilterでインデックスを貼る
②検索条件があればUserInfoIndexで貼ったインデックスを参照してページネーションする
といった流れで条件検索をすることができました。

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

React + Unstated Next: 複数コンポーネントのツリーの中で状態を共有して管理する

Unstated Nextは、複数コンポーネントにより組み立てられたツリーの中で、状態を共有して管理するライブラリです。簡単なサンプルをつくりながら、使い方についてご紹介します。

Unstated Nextの特徴

Reactにフックが採り入れられて、useContextを使えばReduxに頼らなくても、扱う状態の規模がさほど大きくなければ手軽に管理できるようになりました。Unstated Nextは、それをさらにシンプルにしてくれるライブラリです。

Reactのカスタムフックコンテクストがわかっていれば、すぐに使いはじめられます。APIが最小限にまとめられ、ライブラリのサイズはわずか200バイトです。

Reactのコンテクストに当たる状態のまとめ役を、Unstated Nextではコンテナと呼びます。ひとつの状態をまとめて管理するReduxと比べると、コンテナは小分けできることもパフォーマンスの点からは有利です。ただしよく考えて設計しないと、結局コンテナが何重にも入れ子になってしまうことには、注意しなければなりません。

はじめの一歩

まず、Reactアプリケーションのひな形は、Create React Appでつくりましょう。コマンドラインツールでnpx create-react-appにつづけて、アプリケーション名(今回はreact-unstated-next-example)を打ち込んでください。

npx create-react-app react-unstated-next-example

アプリケーション名でつくられたディレクトリに切り替えて(cd react-unstated-next-example)、コマンドyarn startでひな形アプリケーションのページがローカルホスト(http://localhost:3000/)で開くはずです。

つぎに、このディレクトリにインストールするのはUnstated Next(unstated-next)です。yarn addコマンドでライブラリが加えられます。

yarn add unstated-next

yarnでなく、npm installコマンドでインストールしても構いません。

npm install --save unstated-next

カスタムフックとuseContextを使ってつくるカウンター

カスタムフックとコンテクストがわかれば、Unstated Nextはすぐに使えます。ということで、まずは素のReactのカスタムフックとuseContextだけで、ライブラリは使わずにカウンターのアプリケーションをつくってみましょう。

カスタムフックをつくる

「フックとは、関数コンポーネントにstateやライフサイクルといった Reactの機能を"接続する(hook into)"ための関数です」(「要するにフックとは?」)。さらに、フックを独自につくって、コンポーネントからロジックを切り出すこともできます。そうすれば、コンポーネントのコードがすっきり見やすくなるとともに、そのカスタムフックを使い回すこともできるのです。

自分独自のフックを作成することで、コンポーネントからロジックを抽出して再利用可能な関数を作ることが可能です。
(「独自フックの作成」より)

カスタムフックのモジュールsrc/useCounter.jsの定めはコード001のとおりです。カウンターのロジックですから、値の状態変数(count)にその設定関数(setCount())、減算(decrement())と加算(increment())の関数を加えました。

カスタムフックは、関数コンポーネントと異なり、JSXで要素を返す必要はありません。戻り値のオブジェクトに収めたのは、状態変数(count)と減算(decrement())および加算(increment())の関数です。なお、カスタムフックの基本的な役割や考え方については「React: コンポーネントのロジックをカスタムフックに切り出す ー カウンターの作例で」をお読みください。

コード001■カウンターのカスタムフック

src/useCounter.js
import { useState } from "react";

export const useCounter = (initialState = 0) => {
    const [count, setCount] = useState(initialState);
    const decrement = () => setCount(count - 1);
    const increment = () => setCount(count + 1);
    return { count, decrement, increment };
};

カウンターを表示・操作するコンポーネントの作成

カウンターを表示・操作するコンポーネントが以下のコード002です。フックuseContextは、このあとアプリケーション(App)でつくられるコンテクスト(CounterContext)から、カスタムフック(useCounter)が返すオブジェクト(counter)を取り出します。その中の状態変数(counter.count)や減算(counter.decrement)・加算(counter.increment)の関数を、それぞれの要素に割り当てればよいのです。

コード002■カウンター表示のコンポーネント

src/CounterDisplay.js
import React, { useContext } from "react";
import { CounterContext } from './App';

const CounterDisplay = () => {
    const counter = useContext(CounterContext);
    return (
        <div>
            <button onClick={counter.decrement}>-</button>
            <span>{counter.count}</span>
            <button onClick={counter.increment}>+</button>
        </div>
    );
}
export default CounterDisplay;

コンテクストのProviderで子コンポーネントを包む

アプリケーションのモジュール(src/App.js)でコンテクスト(CounterContext)をつくります(コード003)。そのために呼び出す関数がcreateContext()です。コンテクストはContext.Providerコンポーネントを備えています。このコンポーネントに含めた子はすべて、コンテクストが参照できるという仕組みです。

コンテクストに与える変数や関数の参照は、Context.Providerコンポーネントのvalueプロパティに与えてください。今回はカスタムフックuseCounterから得たオブジェクト(counter)が渡されました。

コード003■コンテクストのProvidervalueに参照するオブジェクトを与える

src/App.js
import React, { createContext } from 'react';
import { useCounter } from './useCounter';
import CounterDisplay from './CounterDisplay';
import './App.css';

export const CounterContext = createContext();
function App() {
    const counter = useCounter();
    return (
        <CounterContext.Provider value={ counter }>
            <div className="App">
                <CounterDisplay />
            </div>
        </CounterContext.Provider>
    );
}
export default App;

これで、コンテクストを使ったカウンターができあがりました(図001)。

図001■コンテクストを使ったカウンター

2009001_001.png

Unstated Nextでカウンターをつくり替える

カスタムフックとコンテクストがわかりましたので、カウンターをUnstated Nextで動くようにつくり直して見ましょう。コンテクストに替えて、コンテナをつくります。

カスタムフックからコンテナをつくる

モジュールsrc/useCounter.jsのカスタムフックは基本的に変わりません。フックを関数createContainer()でコンテナに包むのです。コンテナ(CounterContainer)は、いわばカスタムフック(useCounter)のロジックを備えたコンテクストといえます。

src/useCounter.js
import { createContainer } from "unstated-next";

// export const useCounter = (initialState = 0) => {
const useCounter = (initialState = 0) => {

};

export const CounterContainer = createContainer(useCounter);

コンポーネントをコンテナのProviderで包む

アプリケーションモジュールsrc/App.jsは、コンテクストをUnstated Nextのコンテナに差し替えます。コンテナにもコンテクストと同じように<Container.Provider>が備わっているのです。Providerもコンテクストからコンテナに書き替えてください。ただし、valueプロパティは要りません。

src/App.js
// import React, { createContext } from 'react';
import React from 'react';
// import { useCounter } from './useCounter';
import { CounterContainer } from './useCounter';

// export const CounterContext = createContext();
function App() {
    // const counter = useCounter();
    return (
        // <CounterContext.Provider value={ counter }>
        <CounterContainer.Provider>

        {/* </CounterContext.Provider> */}
        </CounterContainer.Provider>
    );
}

コンテナのロジックをコンポーネントが使う

コンテナはカスタムフックのロジックを備えているのでした。コンテナ(CounterContainer)に対してuseContainer()を呼び出すと、ロジックの参照が得られるのです。参照はコンテクストを使ったときと同じ変数(counter)に収めれば、ほかに書き直すところはありません。

src/CounterDisplay.js
// import React, {useContext} from "react";
import React from "react";
// import { CounterContext } from './App';
import { CounterContainer } from './useCounter';

function CounterDisplay() {
  // const counter = useContext(CounterContext);
  const counter = CounterContainer.useContainer();

}

これでカウンターはUnstated Nextのコードに書き替えられました。モジュール3つの記述を以下のコード004にまとめます。カスタムフックとコンテクストでも組み立ては簡単でした。でも、ふたつをまとめたコンテナを使うことでさらにシンプルになったでしょう。CodeSandboxに作例をサンプル001として掲げました。

コード004■Unstated Nextを使ったカウンター

src/useCounter.js
import { useState } from "react";
import { createContainer } from "unstated-next";

const useCounter = (initialState = 0) => {
    const [count, setCount] = useState(initialState);
    const decrement = () => setCount(count - 1);
    const increment = () => setCount(count + 1);
    return { count, decrement, increment };
};

export const CounterContainer = createContainer(useCounter);
src/App.js
import React from 'react';
import { CounterContainer } from './useCounter';
import CounterDisplay from './CounterDisplay';
import './App.css';
function App() {
    return (
        <CounterContainer.Provider>
            <div className="App">
                <CounterDisplay />
            </div>
        </CounterContainer.Provider>
    );
}
export default App;
src/CounterDisplay.js
import React from "react";
import { CounterContainer } from './useCounter';

const CounterDisplay = () => {
  const counter = CounterContainer.useContainer();
    return (
        <div>
            <button onClick={counter.decrement}>-</button>
            <span>{counter.count}</span>
            <button onClick={counter.increment}>+</button>
        </div>
    );
}
export default CounterDisplay;

サンプル001■ Unstated Nextでカウンターをつくる

2009001_002.png
>> CodeSandboxへ

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

綺麗なReactコンポーネント設計でモノリシックなコンポーネントを爆殺する

まずはじめに

Reactはユーザインターフェース構築のためのJavaScriptライブラリです。
React は、インタラクティブなユーザインターフェイスの作成にともなう苦痛を取り除きます。アプリケーションの各状態に対応するシンプルな View を設計するだけで、React はデータの変更を検知し、関連するコンポーネントだけを効率的に更新、描画します。
- React公式より

Reactのプロジェクトである程度規模が大きくなっていくと問題になっていくのは
きちんと設計しないとビジネスロジック、コンポーネントのステート、表示
これらが入り混じって数百行の巨大なコンポーネント(モノリシックなコンポーネント)ができてしまう場合があることです。
確かにReactはユーザインタラクティブなViewの作成には強力な力を発揮しますが、
綺麗なコンポーネント設計に関しては利用者に委ねられています。
(Reactが提供しているのはMVCモデルのViewControllerの部分です)


端的に言ってしまえば、
ビジネスロジックと表示の部分の分離の面倒まではReactは見てくれないので
気をつけないとビジネスロジックと表示が密結合になり、
著しく流用性、保守性が下がるコンポーネントになっていきます。

プロジェクトが大規模になるほど、モノリシックなコンポーネントの保守拡張は厄介になるため、
そもそもモノリシックにならないようにコンポーネントを設計する必要があります。
本記事では綺麗なコンポーネントを設計するためのテクニックを幾つか紹介します。

  • 流用しやすいViewコンポーネント設計
  • Presentation(ビジネスロジック)とViewを分離する
  • Compound Componentで分岐を綺麗にする

Gitサンプルは簡易のためParcelで構築しています。
環境構築に関しては割愛とさせていただきます。

サンプルは以下で起動できます。(typescript+eslint+prettier設定済み)

$ yarn
$ yarn start

流用しやすいViewコンポーネント設計

UIコンポーネント表示の粒度として概念的にわかりやすいものでAtomic Designがあります。

https___qiita-image-store.s3.amazonaws.com_0_150569_73ffc43a-a181-fad9-a16e-f9b894475f85.png

  • ATOMS(原子): UIコンポーネントの最小単位、単一のボタン、単一のテキストボックスなど
  • MOLECULES(分子): 原子を組み合わせたもの、性別のラジオグループなど
  • ORGANISMS(組織): フォーム、リスト、グリッドなどのコレクションなど複数の分子を格納している粒度
  • TEMPLATES(雛形): ページのスケルトン(ページにデータを流し込む前の状態)、概念としてはあるけどあんまり使われない
  • PAGES(ページ): ページそのものの単位の粒度

流用可用性としてせいぜいATOMSとMOLECULESまでくらいでしょう。
ORGANISMSまでいくとレイアウトそのものにもプロジェクトの色が濃く反映され、他のプロジェクトなどへの流用は難しくなります。

流用性が高いコンポーネントの特徴としては

  • props名にドメイン名を含めず、抽象化されている(NG:profileName→OK:name)
  • margin,top,left,bottom,rightなどのレイアウトの外側の余白、位置(できればサイズも)を決定するスタイルを持たない&props経由でスタイルを上書きすることができる

参考:スタイルクローズドの原則

要は、ORGANISMS以下でページ内の位置を決定するのはおかしなことで、ページの方で使用するコンポーネントの位置を決定するmarginなどを割り振りできるようにすべし

逆に流用性が低くならざる得ない場合も当然あると思います。その場合は割り切って利用用途(ドメイン)に応じてコンポーネントをフォルダ分けしたほうがいいでしょう

Presentation(ビジネスロジック)とViewを分離する

表示とビジネスロジック(表示条件、表示データなど)を分離するにはいくつか方法があります。

  • 高階コンポーネント(HOC)
  • children propsの拡張
  • componentのprops渡し

いずれもビジネスロジックハンドリング用のPresentationコンポーネントと表示専用のViewコンポーネントを分離するためのパターンです。
LogicPage.tsxに実際の使用例をまとめてあります。
いずれもLogを出力するというロジックをViewコンポーネントから分離しています。

LogicPage.tsx
import React from 'react'
import withLogger from '../logics/LoggerHOC'
import LoggerChildrenProps from '../logics/LoggerChildrenProps'
import LoggerWithProps from '../logics/LoggerWithProps'
import TextButton from '../moleculars/TextButton'

const LogTextButton = ({ log }: { log?: string }) => (
  <TextButton
    onClick={(text) => {
      console.log(log)
      console.log(text)
    }}
  />
)

const WrapTextButton = withLogger(LogTextButton)

const LogicPage = (): JSX.Element => {
  return (
    <div>
      <h1>HOC</h1>
      <WrapTextButton log="high order components" />
      <h1>childrenpropsの拡張</h1>
      <LoggerChildrenProps log="with children props">
        <LogTextButton />
        ほげ
      </LoggerChildrenProps>
      <h1>componentprops渡し</h1>
      <LoggerWithProps log="with props" component={LogTextButton} />
    </div>
  )
}

export default LogicPage

棲み分けとしては次のようなイメージです。

  • レンダリングに必要なpropsの取得やデータ送信(APIコールなど)はPresentationコンポーネントで行う
  • ViewコンポーネントではAPIコールやビジネスロジックを伴う直接的な判定を行わない、自身の表示の状態(state)切り替えなどは持ってもよい

高階コンポーネント(HOC)

High Order Component(HOC)は
高階関数にて既存のコンポーネントをwrapして
propsや処理を拡張する手法です。

LoggerHOC.tsx
import React from 'react'

type InjectProps = { log: string }

function withLogger<T>(Component: React.ComponentType<T & InjectProps>) {
  return function wrap(props: T & InjectProps): JSX.Element {
    const { log } = props
    // ロジックをねじ込む
    React.useEffect(() => {
      console.log(`${log} mount`)

      return () => console.log(`${log} unmount`)
    }, [])

    return <Component {...props} />
  }
}

export default withLogger

使い方は既存のコンポーネントをHOC関数でwrapした上で使います。
wrap元のコンポーネントに影響を与えない反面、呼び出され元がwrapされているものなのか判別が厄介になるデメリットもあります。

const WrapTextButton = withLogger(LogTextButton)

return <WrapTextButton log="high order components" />

children propsの拡張

childrenのコンポーネントをReact.cloneElement関数にてprops拡張する手法もあります。
注意点としては文字列や複数の子が入る場合もあるのでその対応も必要になります。

LoggerChildrenProps.tsx
import React from 'react'

type InjectProps = { log: string }

function LoggerChildrenProps({
  log,
  children,
}: InjectProps & {
  children: React.ReactChild | React.ReactChild[]
}): JSX.Element {
  // ロジックをねじ込む
  React.useEffect(() => {
    console.log(`${log} mount`)

    return () => console.log(`${log} unmount`)
  }, [])

  // childrenだと文字列や複数の子も許容してしまうため対応する
  const childrenWithProps = React.Children.map(children, (child) => {
    switch (typeof child) {
      case 'string':
        return child
      case 'object':
        return React.cloneElement(child as React.ReactElement, { log })
      default:
        return null
    }
  })

  return <>{childrenWithProps}</>
}

export default LoggerChildrenProps

使用側は入れ子にするだけで、拡張されたpropsが子コンポーネントに渡されます。

  <LoggerChildrenProps log="with children props">
     <LogTextButton />
     ほげ
  </LoggerChildrenProps>

componentのprops渡し

これが一番直感的かもしれません。componentという名のpropsにコンポーネントを渡します。
children propsの拡張と違うのは子が必ず1つだけなのと文字列を入れる想定がないため実装がシンプルです。

LoggerWithProps.tsx
import React from 'react'

type InjectProps = { log: string }

function LoggerWithProps({
  log,
  component,
}: {
  log: string
  component: React.ComponentType<InjectProps>
}): React.ReactElement {
  // ロジックをねじ込む
  React.useEffect(() => {
    console.log(`${log} mount`)

    return () => console.log(`${log} unmount`)
  }, [])

  const Component = component
  return <Component log={log} />
}

export default LoggerWithProps

使用側はpropsに渡すだけで、拡張されたpropsが子コンポーネントに渡されます。

<LoggerWithProps log="with props" component={LogTextButton} />

Compound Componentで分岐条件を隠蔽化する

Reactのデザインパターン Compound Componentsを参考にReact Hook化しています。
Hook版Compound Componentの参考:React Hooks: Compound Components

例えば、次のようなif文もしくは?演算子によるレンダリングの分岐が膨れ上がっていくとすると
一体どの条件で何がレンダリングされるのか直感的ではありません。

 render() {
    if (this.state.currentTabType === TAB_TYPES.HOME) {
      return <div>Homeの時の中身</div>;
    } else if (this.state.currentTabType === TAB_TYPES.ABOUT) {
      return <div>Aboutの時の中身</div>;
    } else if (this.state.currentTabType === TAB_TYPES.OTHERS) {
      return <div>OTHERSの時の中身</div>;
    }
    return null;
 }

Compound Componentsパターンを導入することで
次のように条件分岐が隠蔽化されて、表示部分のみが可視化され非常に直感的になります。

MenuPage.tsx
const MenuPage = (): JSX.Element => {
  return (
    <Menu>
      <Menu.Tabs />
      <div
        style={{
          width: 300,
          height: 300,
          border: '1px solid black',
          padding: 10,
        }}
      >
        <Menu.Home>Homeの時の中身</Menu.Home>
        <Menu.About>Aboutの時の中身</Menu.About>
        <Menu.Others>Othersの時の中身</Menu.Others>
      </div>
    </Menu>
  )
}

今回はタブの状態管理をuseMenuカスタムフックに分離しています。(後述のテストに使う)

useMenu.tsx
import React from 'react'

export type ValueOf<T> = T[keyof T]

export const TAB_TYPES = {
  HOME: 'home',
  ABOUT: 'about',
  OTHERS: 'others',
}

export const tabData = [
  {
    text: 'Home',
    type: TAB_TYPES.HOME,
  },
  {
    text: 'About',
    type: TAB_TYPES.ABOUT,
  },
  {
    text: 'Others',
    type: TAB_TYPES.OTHERS,
  },
]

export const useMenu = (): {
  tabType: ValueOf<typeof TAB_TYPES>
  changeTab: (tabType: ValueOf<typeof TAB_TYPES>) => void
} => {
  const [tabType, setTabType] = React.useState<ValueOf<typeof TAB_TYPES>>(
    TAB_TYPES.HOME
  )

  const changeTab = React.useCallback(
    (tabType: ValueOf<typeof TAB_TYPES>) => {
      setTabType(tabType)
    },
    [tabType]
  )

  return { tabType, changeTab }
}

Menu.tsxで具体的にCompound Componentを実装しています。
ポイントなるのがContext API(TabContext.Provider+useContext)で末端の各Tabコンポーネントに親元のMenuコンポーネントの状態を伝えています。
メニュー部を表示しているのがTabsコンポーネントでタブの中身を表示しているのがHome、About、Othersの各種コンポーネントです。

Menu.tsx
import React, { useContext } from 'react'
import { ValueOf, TAB_TYPES, tabData, useMenu } from '../../hooks/useMenu'

const TabContext = React.createContext<{
  tabType: ValueOf<typeof TAB_TYPES>
  changeTab: (tabType: ValueOf<typeof TAB_TYPES>) => void
}>({
  tabType: TAB_TYPES.HOME,
  changeTab: () => null,
})

function Menu({ children }: { children: React.ReactNode }): JSX.Element {
  const { tabType, changeTab } = useMenu()

  return (
    <TabContext.Provider
      value={{
        tabType,
        changeTab,
      }}
    >
      {children}
    </TabContext.Provider>
  )
}

function Home({ children }: { children?: React.ReactNode }) {
  const { tabType } = useContext(TabContext)

  return tabType === TAB_TYPES.HOME ? (children as JSX.Element) : null
}

function About({ children }: { children?: React.ReactNode }) {
  const { tabType } = useContext(TabContext)

  return tabType === TAB_TYPES.ABOUT ? (children as JSX.Element) : null
}

function Others({ children }: { children?: React.ReactNode }) {
  const { tabType } = useContext(TabContext)

  return tabType === TAB_TYPES.OTHERS ? (children as JSX.Element) : null
}

function Tabs() {
  const { tabType, changeTab } = useContext(TabContext)

  return (
    <ul style={{ display: 'flex', padding: 0 }}>
      {tabData.map((tab) => (
        <li
          key={tab.type}
          style={{
            display: 'block',
            color: tabType === tab.type ? 'black' : 'grey',
            marginRight: 5,
            padding: 0,
            cursor: 'pointer',
          }}
          onClick={() => changeTab(tab.type)}
        >
          {tab.text}
        </li>
      ))}
    </ul>
  )
}

Menu.Tabs = Tabs
Menu.Home = Home
Menu.About = About
Menu.Others = Others

export default Menu

カスタムフックのテスト

原則ビジネスロジックをあまりフロントエンドに寄せない方が良いのですが、
(bundleの肥大化、どのみちバックエンドでのAPIでの判定が必要など)
カスタムフックそのもののテストを@testing-library/react-hooksを使うことで行うこともできます。
jestを使ってのテスト環境を構築します。

$ yarn jest ts-jest @types/jest babel-jest react-test-renderer @testing-library/react @testing-library/react-hooks

package.jsonにjestの設定を記載します。

package.json
{
  "scripts": {
    "test": "jest",
  },
  "jest": {
    "moduleFileExtensions": [
      "js",
      "ts",
      "tsx"
    ],
    "transform": {
      "^.+\\.tsx?$": "ts-jest"
    },
    "globals": {
      "ts-jest": {
        "tsConfig": "tsconfig.json"
      }
    },
    "testMatch": [
      "**/__tests__/**/*.test.ts"
    ]
  }
}

src/__tests__以下にカスタムフックのテストを書きます。
renderHookでカスタムフックの戻り値を取得します。
actでカスタムフックのシミュレーションを行うことが出来ます。

useMenu.test.ts
import { act, renderHook } from '@testing-library/react-hooks'
import { useMenu, TAB_TYPES } from '../hooks/useMenu'

it('tab toggle', () => {
  const { result } = renderHook(() => useMenu())
  // 初期状態(Homeタブ)
  expect(result.current.tabType).toBe(TAB_TYPES.HOME)

  // Aboutタブに切り替え
  act(() => {
    result.current.changeTab(TAB_TYPES.ABOUT)
  })
  expect(result.current.tabType).toBe(TAB_TYPES.ABOUT)

  // Othersタブに切り替え
  act(() => {
    result.current.changeTab(TAB_TYPES.OTHERS)
  })
  expect(result.current.tabType).toBe(TAB_TYPES.OTHERS)
})

jestコマンドでカスタムフックの単体テストを行うことが出来ます。

$ yarn jest
yarn run v1.22.4
$ /Users/teradonburi/Desktop/ts-react/node_modules/.bin/jest
 PASS  src/__tests__/useMenu.test.ts
  ✓ tab toggle (13 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.575 s, estimated 3 s
Ran all test suites.
✨  Done in 3.15s.

参考:React Hooks のテストを react-hooks-testing-library で書く

Storybookでショーケースを作っておく

コンポーネントのテストには
enzymeなどのテストライブラリで単体テストをする方法もありますが、
ビジュアル部分に関してテストすることはできません。
またコンポーネントそのものの使い勝手の良さなどは実際にすぐにいじれる環境が必要です。
storybookの導入はsb initコマンドで行います。

$ yarn add --dev @storybook/cli
# プロジェクトのReact、VueなどのフレームワークとTypescript有無などを勝手に判別して適切な設定で初期化してくれる
$ npx @storybook/cli sb init

.storybookフォルダとstoriesフォルダ(+サンプル)が生成されます。
storiesフォルダの自動生成サンプルは一旦ごっそり消して、実際に使用しているコンポーネントのショーケースを作成します。
今回は自作したButtonのショーケースを作成しています。

stories/Button.stories.tsx
import React from 'react'
import { Story, Meta } from '@storybook/react/types-6-0'

import Button, { ButtonProps } from '../components/atoms/Button'

// 表示するコンポーネント
export default {
  title: 'Example/Button',
  component: Button,
} as Meta

const Template: Story<ButtonProps> = (args) => <Button {...args} />

// 表示されるショーケースの名前
export const Normal = Template.bind({})
// コンポーネントのprops
Normal.args = {
  value: '送信',
}

以下のコマンドでstorybookサーバが起動します。

$ npx start-storybook -p 6006

作成したショーケースが閲覧できます。

スクリーンショット 2020-09-23 1.44.08.png

Visual Regression Test(表示回帰テスト)

storybookにショーケースを作成しておくことで表示のデグレが起きていないかテスト(特にMaterial-UIなどのUI系のライブラリを導入している場合はライブラリバージョンを上げた際の確認用にやっておいたほうが良い)
storybook公式ではChromaticを推しています。
GitHubなどのリポジトリ単位で連携しかつstorybookの全ショーケースに対して
Visual Regression Testを行ってくれます。

$ yarn add --dev chromatic
$ npx chromatic --project-token {Chromaticのトークン}

スクリーンショット 2020-09-22 19.50.00.png

以下はfontSize変更した際に検出された表示差分です。

スクリーンショット 2020-09-22 19.54.05.png

Github連携するとIntegrationsにChromaticが追加されます。

スクリーンショット 2020-09-23 0.40.38.png

Github Actions、CircleCI、Travis各種CIへ導入することももちろんできます。
ただ、Github Actionsでのactionsも用意されているのですが、試してみたところ上手く動かなかったので直接chromatic-cliのコマンドを実行しています。

.github/workflows/main.yml

main.yml
# This is a basic workflow to help you get started with Actions

name: CI

# Controls when the action will run. Triggers the workflow on push or pull request
# events but only for the master branch
on:
  push:
    branches: [master]
  pull_request:
    branches: [master]

# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
  # This workflow contains a single job called "build"
  build:
    # The type of runner that the job will run on
    runs-on: ubuntu-latest

    # Steps represent a sequence of tasks that will be executed as part of the job
    steps:
      - uses: actions/checkout@v2
        with:
          fetch-depth: 0
      - name: run
        run: |
          yarn 
          yarn chromatic ${{ secrets.CHROMATIC_TOKEN }}

yarn chromaticのコマンドはpackage.jsonにて以下のようなコマンドです。

package.json
  "scripts": {
    "chromatic": "npx chromatic --project-token"
  },

secrets.CHROMATIC_TOKENはchromaticより払い出しされたトークンでGitHubのSecrets項目に設定している想定です。

スクリーンショット 2020-09-23 0.38.16.png

設定完了するとGithub Actionsが動くようになります。

スクリーンショット 2020-09-23 0.37.03.png

Github連携が完了しているとBranch protection rulesにUI ReviewとUI Testsが現れます。
これらの項目をPR merge条件として必須化させることもできます。

スクリーンショット 2020-09-23 0.40.08.png

Chromaticは5000 snapshot/monthまで無料で、個人用プロジェクトレベルならあまり問題ないですが、
料金が気になるという場合はBackStopJSライブラリなどを使って自前でVisual Regression Testをする方法もあります。(その際、比較元の環境が必要ですが・・・)

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

Firestoreでリアルタイムな無限スクロールを実現する

動機

FirestoreのSnapshotListenerを使えばリアルタイムチャットのような機能を比較的容易に作成することができます。しかし一方で、特別な制御を行わなければメッセージを一度に購読してしまい、場合によってはデータ量が非常に大きくなってしまいます。これを避けるために、無限スクロールをSnapshotListenerを使いつつ実現する方法に挑戦しました。

制作したもの

ReactとFirestoreを使って無限スクロールが行える簡易Chatアプリを作ってみました

ChatappCapture.gif

使用技術

  • React : 16.8.0以降のHooksがサポートされているもの
  • Firestore : SnapshotListenerについては公式を参照ください

期待動作

Chatアプリでは次の動作を満たすものとします。
また、今回の前提として購読中メッセージは物理的に削除されないこととします。

  1. 初回ロード時に直近一定数のChatメッセージを表示する
  2. 新しいメッセージが追加されたら、リアルタイムにChatウィンドウに追加する
  3. 上向きにスクロールすることで過去のメッセージを一定数ずつ追加する
  4. メッセージが編集された場合、リアルタイムに表示を更新する

実現方法

この機能を実現するために、Firestoreの制御とスクロールの制御に分けて解説します

Firestoreの制御

メッセージを購読するためにSnapshotListenerを登録します。無限スクロールにおけるSnapshotListenerは大きく、未来/過去の2種類の購読方法によって実現しています。
このような制御を分ける理由としては、購読するメッセージ数が増加する可能性のある「未来」のメッセージに対してlimit関数を適用しないためです。この理由については以下の記事を参照ください。

firestoreのonSnapshotを使う際に気を付けたいこと

çhatapp.png

1. 未来(最新メッセージ)の購読リスナー

新しく投稿されたメッセージを受け取るためのリスナーを登録します。startAfterを用いて、現在時刻以降の全ての新規メッセージを購読しています。

const db = firebase.firestore();
const now = Date.now();
...
// 未来(最新メッセージ)のSnapshotListener登録
db.collection('messages')
  .orderBy('date','asc') // field 'date'は数値とする
  .startAfter(now)
  .onSnapshot((snapshot)=>{
     ...// データ取得
  })

2.過去メッセージの購読リスナー

初期表示及び、スクロール時に追加読み込みするためのリスナーを登録します。orderBystartAfterを用いて、現時刻より前のメッセージを新しい順に購読しています。また、limitを使ってスクロール時に購読するメッセージ数を制御しています。

//過去メッセージの購読リスナー
const registPastMessageListener = useCallback((startAfter:number){
   return db.collection('messages')
     .orderBy('date','desc') // 日付の新しいデータから取得する
     .startAfter(startAfter)
     .limit(limit)
     .onSnapshot((snapshot)=>{
      ...// データ取得
     })
},[])

3.全体のコードイメージ

Firestoreの制御全体のコードの流れは概ね以下のようになります。useInfiniteSnapshotListenerというカスタムフックをexportし、これをスクロールするコンポーネント側から呼び出します。

const db = firebase.firestore();
const now = Date.now();
type Unsubscribe = () => void
type Message = {id:string, ...}
...
function useInfiniteSnapshotListener(){

   const unsubscribes = useRef<Unsubscribe[]>([])
   const [messages, setMessages] = useState<Message[]>([])
   ...
   // 未来(最新メッセージ)の購読リスナー
   const registLatestMessageListener = useCallback(()=>{
     return db.collection('messages')
       .orderBy('date','asc') // field 'date'は数値とする
       .startAfter(now)
       .onSnapshot((snapshot)=>{
        ...// setMessagesを呼び出す(後述)
       })
   },[])

   //過去メッセージの購読リスナー
   const registPastMessageListener = useCallback((startAfter:number)=>{
      return db.collection('messages')
        .orderBy('date','desc') // 日付の新しいデータから取得する
        .startAfter(startAfter)
        .limit(limit)
        .onSnapshot((snapshot)=>{
        ...// setMessagesを呼び出す(後述)
        })
   },[])

   // 初回ロード
   const initRead = useCallback(()=>{
      // 未来のメッセージを購読する
      unsubscribes.current.push(registLatestMessageListener())
      // 現時刻よりも古いデータを一定数、購読する
      unsubscribes.current.push(registPastMessageListener(now))
   },[registPastMessageListener])

   // スクロール時、追加購読するためのリスナー
   const lastMessageDate = messages[messages.length-1].date
   const readMore = useCallback(()=>{
      unsubscribes.current.push(registPastMessageListener(lastMessageDate))
   },[registPastMessageListener,lastMessageDate])

   // 登録解除(Unmount時に解除)
   const clear = useCallback(()=>{
      for (const unsubscribe of unsubscribes.current) {
         unsubscribe()
      }
   },[])

   useEffect(() => {
      return () => {
         clear();
      };
   }, [clear])

   return {
      initRead,
      readMore,
      messages
   }
}

また、onSnapshot内は次のような処理が含まれます。

function onSnapshot (snapshot) {
    let added: Message[] = [];
    let modified: Message[] = [];
    let deleted: Message[] = [];
    for (let change of snapshot.docChanges()) {
        const data = change.doc.data() as Message;
        const target = {
            id: change.doc.id,
            ...data
        };
        if (change.type === 'added') {
            added.push(target)
        }
        else if (change.type === 'modified') {
            modified.push(target)
        }
        else if (change.type === 'removed') {
            deleted.push(target)
        }
    }
    if (added.length > 0) {
        // 追加時
        setMessages(prev=>[...prev,added])
    }
    if (modified.length > 0) {
        // 変更時
        setMessages(prev => {
            return prev.map(mes => {
                const found = modified.find(m => m.id === mes.id);
                if (found) {
                    return found;
                }
                return mes;
            });
        })
    }
    if (deleted.length > 0) {
        // 削除する(今回この操作は扱わない)
    }
}

スクロール制御

スクロールによって最後のメッセージまでたどり着いた後、まだ読み込みが可能なメッセージがfirestoreに残されている場合、上記で定義したreadMoreを呼び出す制御を行います

1. 読み込みが可能なメッセージが残されているかどうかの判断

最も古いメッセージを番兵として持っておき、このメッセージが読み込まれたかどうかで判断します。

const [sentinel, setSentinel] = useState<Message>()

useEffect(()=>{
    db.collection('messages')
        .orderBy('date','asc') // 最も古い日付のデータ
        .limit(1)
        .get()
        .then((querySnapshot)=>{
        // setSentinelを呼び出す
    })
},[])

const hasMore = sentinel ? !Boolean( messages.find(m => m.id === sentinel.id)) : false

2. スクロール上端検知と追加購読

スクロール領域の上端や下端を検知した時に、追加読み込みするためのライブラリはすでに公開されているものが多く、react-infinite-scrollerのように多様な使い方ができるものもあります(一般的にInfinite Scrollと呼ばれるもの)。
今回のChatアプリでは自作したものを用いていますが、SimpleInfiniteScrollerを上記のようなライブラリに置き換えることも可能です。
SimpleInfiniteScrollerについてはここでの説明は割愛しますが、こちらにコードを公開しています。

return ( // 上スクロール時にreadMoreが呼び出される
<SimpleInfiniteScroller
    canScrollUp={hasMore}
    loadMore={readMore}
    reverse 
>
    <ul style={{ overflowY : 'auto', height : '70vh'}}>
        {messages.map(m => (
            <li key={m.id}>{...}</li>
        ))}
    </ul>
</SimpleInfiniteScroller>

3.全体のコードイメージ

スクロール制御全体のコードの流れは概ね以下のようになります。

function Component(){
    const [node,setNode] = useState<HTMLElement>()
    const [sentinel, setSentinel] = useState<Message>()
    const { messages, readMore, initRead } = useInfiniteSnapshotListener()

    // 番兵の読み込み
    useEffect(()=>{
        db.collection('messages')
            .orderBy('date','asc') // 最も古い日付のデータ
            .limit(1)
            .get()
            .then((querySnapshot)=>{
            // setSentinelを呼び出す
        })
    },[])

    // 初回読み込み
    useEffect(()=>{
        initRead();
    },[initRead])

    const hasMore = sentinel ? !Boolean( messages.find(m => m.id === sentinel.id)) : false

    return (// 上スクロール時にreadMoreが呼び出される
    <SimpleInfiniteScroller
        canScrollUp={hasMore}
        loadMore={readMore}
        reverse 
    >
        <ul style={{ overflowY : 'auto', height : '70vh'}}>
            {messages.map(m => (
                <li key={m.id}>{...}</li>
            ))}
        </ul>
    </SimpleInfiniteScroller>
    )
}

注意事項

メッセージ削除について

上にも記載していますが、今回内容はメッセージを物理削除しないことを前提としています。これは物理削除によってlimitで購読するメッセージの入れ替えをもたらさないためです。そのため、削除についてはフラグを設けるなどの論理削除とする必要があります。

最後に

最後まで読んでいただきありがとうございます。
SnapshotListenerを使った無限スクロールについて、今回は全体の流れ中心に記載しましたが、実際には使い勝手を向上させるために新着メッセージの自動スクロールダウンなどの詳細制御等も必要になってくるかと思います。

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

Reactでローカルストレージとstateを連携させる例とNext.jsの注意点

はじめに

Reactで以下のようにローカルストレージとstateを同期させる場合のサンプルを掲載します。

初回のstate:

  • ローカルストレージに値が保存されていたらその値を使う
  • そうでなければ任意の初期値を与える

stateの更新時:

  • state更新に用いた値をローカルストレージに保存する

ツールはcreate-react-app(以下CRA)とNext.jsの2種類になります。
両者ともに余計なコンポーネントや既存のCSSは消去して確認しています。
なおCRAとNext.jsの基礎的なセットアップ方法と仕組みについては省略します。

サンプルはライトテーマとダークテーマの切替を想定しています。
これを動かすと以下のような画面になります。

スクリーンショット 2020-09-23 6.15.59.png

ちなみにローカルストレージの動きはChromeのApplicationタブ等で確認できます。

記事を書いた背景

言語切替機能を付けたWebサイトをNext.jsで実装していたのですが、Next.jsで少しハマる部分がありました。
機能概要は、初回は日本語で読み込み、日本語か英語か選択した後は次回読み込み時にその状態が保存されるものです。

詳細はstateによりreact-helmetでhtmlタグのlang属性を弄り、また言語切替用Contextの値に代入する仕組み(※)ですが、Context等は本題でないので省略して簡素な例を掲載します。

※ 参考にしたページ:https://ja.reactjs.org/docs/context.html#dynamic-context

その際にローカルストレージ周りについて少しコードを書いて整理したノウハウを記録しておきたいと思いました。

CRA

  1. themeステートの初期値はローカルストレージのthemeキーがあればその値を、無ければ'light'を代入します。
  2. ボタンの押下によりthemeステートに'light'または'dark'を代入し、ローカルストレージにもその値をセットします。
  3. Styled textのスタイルはthemeステートが'light''dark'かにより変わります。
App.js
import React, { useState } from 'react';

function Sample() {
  const [theme, setTheme] = useState(localStorage.getItem('theme') || 'light');

  const setLight = () => {
    setTheme('light');
    localStorage.setItem('theme', 'light');
  };

  const setDark = () => {
    setTheme('dark');
    localStorage.setItem('theme', 'dark');
  };

  const getStyleFromTheme = () => {
    if (theme === 'light') return { backgroundColor: 'lightgray', color: 'black' };
    if (theme === 'dark') return { backgroundColor: 'black', color: 'white' };
  };

  return (
    <div>
      <p>Current theme: {theme}</p>
      <button onClick={setLight}>Light</button>
      <button onClick={setDark}>Dark</button>
      <div style={getStyleFromTheme()}>Styled text</div>
    </div>
  );
}

function App() {
  return (
    <div>
      <Sample />
    </div>
  );
}

export default App;

Next.js

CRAの処理をそのまま流用しようと試みましたが、これは上手くいきません。

_app.js
import React, { useState } from 'react';

function Sample() {
  const [theme, setTheme] = useState(localStorage.getItem('theme') || 'light');

  const setLight = () => {
    setTheme('light');
    localStorage.setItem('theme', 'light');
  };

  const setDark = () => {
    setTheme('dark');
    localStorage.setItem('theme', 'dark');
  };

  const getStyleFromTheme = () => {
    if (theme === 'light') return { backgroundColor: 'lightgray', color: 'black' };
    if (theme === 'dark') return { backgroundColor: 'black', color: 'white' };
  };

  return (
    <div>
      <p>Current theme: {theme}</p>
      <button onClick={setLight}>Light</button>
      <button onClick={setDark}>Dark</button>
      <div style={getStyleFromTheme()}>Styled text</div>
    </div>
  );
}

function MyApp({ Component, pageProps }) {
  return (
    <>
      <Sample />
      <Component {...pageProps} />
    </>
  );
}

export default MyApp;
index.js
export default function Home() {
  return <div></div>;
}

スクリーンショット 2020-09-23 6.28.34.png

これには多少の工夫が必要です。

どうすれば良いのかというと、themeの初期値はundefinedを代入しておきます。
そしてuseEffect(もしくはcomponentDidMount)の内部でローカルストレージによる条件分岐を書きます。

実際のコード例は以下のようになります。

_app.js
import React, { useState, useEffect } from 'react';

function Sample() {
  const [theme, setTheme] = useState(undefined);

  useEffect(() => {
    setTheme(localStorage.getItem('theme') || 'light');
  }, []);

  const setLight = () => {
    setTheme('light');
    localStorage.setItem('theme', 'light');
  };

  const setDark = () => {
    setTheme('dark');
    localStorage.setItem('theme', 'dark');
  };

  const getStyleFromTheme = () => {
    if (theme === 'light') return { backgroundColor: 'lightgray', color: 'black' };
    if (theme === 'dark') return { backgroundColor: 'black', color: 'white' };
  };

  return (
    <div>
      <p>Current theme: {theme}</p>
      <button onClick={setLight}>Light</button>
      <button onClick={setDark}>Dark</button>
      <div style={getStyleFromTheme()}>Styled text</div>
    </div>
  );
}

function MyApp({ Component, pageProps }) {
  return (
    <>
      <Sample />
      <Component {...pageProps} />
    </>
  );
}

export default MyApp;

index.js
export default function Home() {
  return <div></div>;
}

これで正常に表示され、一件落着です。

蛇足

調べるとcomponentDidMountuseEffect内でstateを更新することはアンチパターンという情報がありますが、どの程度厳密に守るべきなのか分かりません。筆者はまだReactについて知識が曖昧な点が沢山あるので優しい方はご教示頂けると嬉しいです。

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

Reactでローカルストレージとstateを連携させるサンプル

はじめに

Reactで以下のようにローカルストレージとstateを同期させる場合のサンプルを掲載します。

初回のstate:

  • ローカルストレージに値が保存されていたらその値を使う
  • そうでなければ任意の初期値を与える

stateの更新時:

  • state更新に用いた値をローカルストレージに保存する

ツールはcreate-react-app(以下CRA)とNext.jsの2種類になります。
両者ともに余計なコンポーネントや既存のCSSは消去して確認しています。
なおCRAとNext.jsの基礎的なセットアップ方法と仕組みについては省略します。

サンプルはライトテーマとダークテーマの切替を想定しています。
これを動かすと以下のような画面になります。

スクリーンショット 2020-09-23 6.15.59.png

ちなみにローカルストレージの動きはChromeのApplicationタブ等で確認できます。

記事を書いた背景

言語切替機能を付けたWebサイトをNext.jsで実装していたのですが、Next.jsで少しハマる部分がありました。
機能概要は、初回は日本語で読み込み、日本語か英語か選択した後は次回読み込み時にその状態が保存されるものです。

詳細はstateによりreact-helmetでhtmlタグのlang属性を弄り、また言語切替用Contextの値に代入する仕組みですが、Context等は本題でないので省略して簡素な例を掲載します。

その際にローカルストレージ周りについて少しコードを書いて整理したノウハウを記録しておきたいと思いました。

CRA

  1. themeステートの初期値はローカルストレージのthemeキーがあればその値を、無ければ'light'を代入します。
  2. ボタンの押下によりthemeステートに'light'または'dark'を代入し、ローカルストレージにもその値をセットします。
  3. Styled textのスタイルはthemeステートが'light''dark'かにより変わります。
App.js
import React, { useState } from 'react';

function Sample() {
  const [theme, setTheme] = useState(localStorage.getItem('theme') || 'light');

  const setLight = () => {
    setTheme('light');
    localStorage.setItem('theme', 'light');
  };

  const setDark = () => {
    setTheme('dark');
    localStorage.setItem('theme', 'dark');
  };

  const getStyleFromTheme = () => {
    if (theme === 'light') return { backgroundColor: 'lightgray', color: 'black' };
    if (theme === 'dark') return { backgroundColor: 'black', color: 'white' };
  };

  return (
    <div>
      <p>Current theme: {theme}</p>
      <button onClick={setLight}>Light</button>
      <button onClick={setDark}>Dark</button>
      <div style={getStyleFromTheme()}>Styled text</div>
    </div>
  );
}

function App() {
  return (
    <div>
      <Sample />
    </div>
  );
}

export default App;

Next.js

CRAの処理をそのまま流用しようと試みましたが、これは上手くいきません。

_app.js
import React, { useState } from 'react';

function Sample() {
  const [theme, setTheme] = useState(localStorage.getItem('theme') || 'light');

  const setLight = () => {
    setTheme('light');
    localStorage.setItem('theme', 'light');
  };

  const setDark = () => {
    setTheme('dark');
    localStorage.setItem('theme', 'dark');
  };

  const getStyleFromTheme = () => {
    if (theme === 'light') return { backgroundColor: 'lightgray', color: 'black' };
    if (theme === 'dark') return { backgroundColor: 'black', color: 'white' };
  };

  return (
    <div>
      <p>Current theme: {theme}</p>
      <button onClick={setLight}>Light</button>
      <button onClick={setDark}>Dark</button>
      <div style={getStyleFromTheme()}>Styled text</div>
    </div>
  );
}

function MyApp({ Component, pageProps }) {
  return (
    <>
      <Sample />
      <Component {...pageProps} />
    </>
  );
}

export default MyApp;
index.js
export default function Home() {
  return <div></div>;
}

スクリーンショット 2020-09-23 6.28.34.png

これには多少の工夫が必要です。

どうすれば良いのかというと、themeの初期値はundefinedを代入しておきます。
そしてuseEffect(もしくはcomponentDidMount)の内部でローカルストレージによる条件分岐を書きます。

実際のコード例は以下のようになります。

_app.js
import React, { useState, useEffect } from 'react';

function Sample() {
  const [theme, setTheme] = useState(undefined);

  useEffect(() => {
    setTheme(localStorage.getItem('theme') || 'light');
  }, []);

  const setLight = () => {
    setTheme('light');
    localStorage.setItem('theme', 'light');
  };

  const setDark = () => {
    setTheme('dark');
    localStorage.setItem('theme', 'dark');
  };

  const getStyleFromTheme = () => {
    if (theme === 'light') return { backgroundColor: 'lightgray', color: 'black' };
    if (theme === 'dark') return { backgroundColor: 'black', color: 'white' };
  };

  return (
    <div>
      <p>Current theme: {theme}</p>
      <button onClick={setLight}>Light</button>
      <button onClick={setDark}>Dark</button>
      <div style={getStyleFromTheme()}>Styled text</div>
    </div>
  );
}

function MyApp({ Component, pageProps }) {
  return (
    <>
      <Sample />
      <Component {...pageProps} />
    </>
  );
}

export default MyApp;

index.js
export default function Home() {
  return <div></div>;
}

これで正常に表示され、一件落着です。

蛇足

調べるとcomponentDidMountuseEffect内でstateを更新することはアンチパターンという情報がありますが、どの程度厳密に守るべきなのか分かりません。筆者はまだReactについて知識が曖昧な点が沢山あるので優しい方はご教示頂けると嬉しいです。

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

reduxを5ステップで解説!フロントエンドのデータ管理をどうするか?

reduxとは?

UIのステートをアプリ全体で管理するためのフレームワーク。ReactやAngularJS、Vueなどで使用することができます。

なぜreduxが必要なのか?

なぜreduxが必要なのかというと、ステートをアプリ全体で管理することで読みやすいコードを書けるからです。

reduxを使わなければ保守性が落ちてしまいます。
1.reduxを使わない
2.コンポーネント間のやり取りが増える
3.どこでステートが更新されたのかがわかりづらい

その問題を解決するのがreduxです。
1.ステートをアプリ全体で管理する
2.コンポーネント間のやり取りが減る
3.他のコンポーネントに依存しにくいコードが書ける

reduxの流れ

reduxでのステートを更新の流れは主に4ステップです。

1.UIでイベントが発生
2.reduxに通知(イベントリスナー)
3.ステートの更新(イベントハンドラー)
4.UIのリレンダリング

1.イベントの発生

ユーザーがボタンを押したり、文字を入力したタイミングでイベントが発生します。

2.reduxへの通知

コンポーネントで発生したイベントに該当するアクションをストアーに渡し、
ステートの更新が必要なことを通知します。
アクションには、
・どんなイベントが発生したのか?(type属性)
・どんな値を渡すのか?(payload属性)
という情報が含まれます。

このアクションをストアーに通知するためにdispatchメソッドを経由します。
言い換えるとdispatchはイベントリスナーとしての働きです。

3.ステートの更新

ストアーに変更が通知されるとstoreの中のreducerで
・引き渡されたアクション
・前回のステート
の2つを基に新しいステートに更新します。
言い換えるとreducerはイベントハンドラーとしての働きです。

4.UIのリレンダリング

最後にストアーのステートが更新されるとUIに変更を通知します。
その後、UIはリレンダリングされます。

reduxの実装方法

それでは、実際にReactでreduxを実装していきましょう。
サンプルプログラムは入力項目に入力した内容とセットした回数をカウントするプログラムです。

実装手順は次の5ステップ。
1.reducerの作成
2.アクションクリエーターの作成
3.ストアーの作成
4.ストアーとコンポーネントの連携
5.UIのイベントハンドラーの実装

1.reducerの作成

まずはアクションがストアーに渡されたときに新しいステートに更新する処理を記述していきます。

reducer.jsx
// ステートの初期化
const initialState = {
  count:0,
  input:null
}

// リデューサーを定義 
export default function reducer(state = initialState, action) {
  switch(action.type) {
    case 'SET_INPUT':
      return {
          count:   state.count + 1  //カウントアップ
          , input: action.input     //入力内容をセット
        }
    default:
      return state
  }
}

reducer関数の引数は次の2つです。
・第一引数:前回のステート
・第二引数:アクション
アクションのtype属性で「どんな処理が発生したのか」がわかるので条件分岐させてイベントハンドラーを記述します。

今回のソースでは
・count:前回の回数に+1
・input:入力された値をセット
の2つのステートを更新します。

そして、デフォルト引数では「どんなステートがセットされるのか」を宣言します。
これにより、アクションが初めて呼ばれる際のステートを決めることができます。

2.アクションクリエーターの作成

ストアーに引き渡すアクションを記述していきます。

action.jsx
export function setInput(input) {
  return {
    type: 'SET_INPUT',
    input: input
  }
}

アクションには
・type属性:どんな処理を行うのか?
・payload属性:どんな値が渡ってきたのか?
の2つを記述していきます。payload属性は任意ですが、type属性は必須です。

3.ストアーの作成

アプリ全体で一つのストアーを管理したいためルートコンポーネントであるApp.jsで2つのことを行います。
・ストアーの作成
・ストアーの引き渡し

App.jsx
import React,{ Component } from 'react';
import { BrowserRouter, Switch, Route } from 'react-router-dom';
import { createStore } from 'redux'
import reducer from './redux/reducer'
import { Provider } from 'react-redux';

//screens
import click from './redux/click-con';
import show from './redux/show-con';

// 1.ストアの作成
const store = createStore(reducer);

class App extends Component {
  render() {
    return (
      <BrowserRouter>
        {/* 2.ストアーの引き渡し */}
        <Provider store={store}>
          <Switch>
              <Route exact path="/click" component={click} />
              <Route exact path="/show" component={show} />
          </Switch>
        </Provider>
      </BrowserRouter>
    );
  }
}

export default App;

redux.createStoreメソッドに先ほど作ったreducerを渡して、ストアーを作成します。
このままだとストアーができただけで、コンポーネントにストアーが渡されていない状態です。
そのため、「react-redux」のProviderタグを使って各コンポーネントに渡します。

4.ストアーとコンポーネントの連携

connectメソッドを使ってストアーとコンポーネントを連携します。

click-con.jsx
import { connect } from 'react-redux'
import { setInput } from './action'
import click from '../screen/click'

const mapStateToProps = state => {
   const { input, count } = state
   return { input, count }
 }

 const mapDispatchToProps = dispatch => {
   return {
    setInputClick: (input) => { 
      dispatch(setInput(input));
      alert(`「${input}」がセットされました!`)
    }
   }
 }

export default connect(mapStateToProps,mapDispatchToProps)(click)
show-con.jsx
import { connect } from 'react-redux'
import show from '../screen/show'

const mapStateToProps = state => {
   const { input, count } = state
   return { input, count }
 }
export default connect(mapStateToProps)(show)

このconnectには主に2つの引数を渡します。
・mapStateToProps:必要なデータをストアーから取得
・mapDispatchToProps:ストアーにアクションを通知する関数

mapStateToProps

ストアーから渡されたステートをコンポーネントに渡します。
・第一引数:現在のステート
・戻り値:コンポーネントに渡すステート
また、ストアーのステートをそのまま渡すだけではなく、mapStateToProps内でデータを整形して渡すことができます。
そして、戻り値に設定したステートはコンポーネントの引数に追加されます。

mapDispatchToProps

コンポーネントにストアーに変更を通知するアクションクリエーターを渡します。
・第一引数:dispatchメソッド
・戻り値:ストアーに通知するアクションオブジェクト

dispatchメソッドにアクションオブジェクトを渡すことでストアーに通知できます。
mapStateToPropsと同じように戻り値に渡したオブジェクトはコンポーネントの引数に追加されます。

5.UIのイベントハンドラーの実装

最後にUIのイベントハンドラーにconnectメソッドで引き渡されたdispatchでラップされたアクションクリエータを呼び出します。

click.js
import React, { useState } from 'react';
import { Link } from 'react-router-dom'

export default (props) => {

  const style = {
    textAlign: "center",
    marginTop: "10px"
  };

  const [input, setInput] = useState(props.input);

  /**
   * inputを変更
   */
  const changeInput = (e) => {
    setInput(e.target.value);
  }

  return (
    <div style={style}>
      <input type="text" value={input} onChange={changeInput}></input>
      <button onClick={() => props.setInputClick(input)}>値セット</button>
      <br/><Link to="/show">表示へ</Link>
    </div>
  );
}
show.js
import React from 'react';
import { Link } from 'react-router-dom'

export default (props) => {
  const divStyle = {
    textAlign: "center",
    marginTop: "10px",
    "ul" : {
      listStyle: "none"
    }
  };

  const ulStyle = {
    listStyle: "none"
  }

  return (
    <div style={divStyle}>
      <ul style={ulStyle}>
        <li>カウント:{props.count}</li>
        <li>値:{props.input}</li>
      </ul>
      <Link to="/click">セットへ</Link>
    </div>

  );
}

QA

Q.全てのステートを渡すのはダメ?

A.全てステートをコンポーネントに渡すことは可能ですが、避けたほうが無難です。
コンポーネントに渡すステートが変更されたかどうかを判定してUIはリレンダリングするのかを判断しています。
そのため、全てのステートを渡すと無駄にリレンダリングされる可能性があります。
パフォーマンスを良くしたいのであれば、必要なステートだけをコンポーネントに渡すのが最善です。

ちなみに再処理される条件は以下の通りです。
・mapStateToProps ⇒ ステートが変更されたとき
・UIのリレンダリング ⇒ mapStateToPropsの戻り値が変更されたとき

また、redux内部では変更判定をシャロー比較(===)しているため、配列処理等(Array.filter、 Array.concat)でデータを再生成している際は注意が必要です。

参考

redux公式
react-redux公式
githubサンプル

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

【図解】reduxの使い方

reduxとは?

UIのステートをアプリ全体で管理するためのフレームワーク。ReactやAngularJS、Vueなどで使用することができます。

なぜreduxが必要なのか?

なぜreduxが必要なのかというと、ステートをアプリ全体で管理することで読みやすいコードを書けるからです。

reduxを使わなければ保守性が落ちてしまいます。
1.reduxを使わない
2.コンポーネント間のやり取りが増える
3.どこでステートが更新されたのかがわかりづらい

その問題を解決するのがreduxです。
1.ステートをアプリ全体で管理する
2.コンポーネント間のやり取りが減る
3.他のコンポーネントに依存しにくいコードが書ける

reduxの流れ

reduxでのステートを更新の流れは主に4ステップです。

1.UIでイベントが発生
2.reduxに通知(イベントリスナー)
3.ステートの更新(イベントハンドラー)
4.UIのリレンダリング

1.イベントの発生

ユーザーがボタンを押したり、文字を入力したタイミングでイベントが発生します。

2.reduxへの通知

コンポーネントで発生したイベントに該当するアクションをストアーに渡し、
ステートの更新が必要なことを通知します。
アクションには、
・どんなイベントが発生したのか?(type属性)
・どんな値を渡すのか?(payload属性)
という情報が含まれます。

このアクションをストアーに通知するためにdispatchメソッドを経由します。
言い換えるとdispatchはイベントリスナーとしての働きです。

3.ステートの更新

ストアーに変更が通知されるとstoreの中のreducerで
・引き渡されたアクション
・前回のステート
の2つを基に新しいステートに更新します。
言い換えるとreducerはイベントハンドラーとしての働きです。

4.UIのリレンダリング

最後にストアーのステートが更新されるとUIに変更を通知します。
その後、UIはリレンダリングされます。

reduxの実装方法

それでは、実際にReactでreduxを実装していきましょう。
サンプルプログラムは入力項目に入力した内容とセットした回数をカウントするプログラムです。

実装手順は次の5ステップ。
1.reducerの作成
2.アクションクリエーターの作成
3.ストアーの作成
4.ストアーとコンポーネントの連携
5.UIのイベントハンドラーの実装

1.reducerの作成

まずはアクションがストアーに渡されたときに新しいステートに更新する処理を記述していきます。

reducer.jsx
// ステートの初期化
const initialState = {
  count:0,
  input:null
}

// リデューサーを定義 
export default function reducer(state = initialState, action) {
  switch(action.type) {
    case 'SET_INPUT':
      return {
          count:   state.count + 1  //カウントアップ
          , input: action.input     //入力内容をセット
        }
    default:
      return state
  }
}

reducer関数の引数は次の2つです。
・第一引数:前回のステート
・第二引数:アクション
アクションのtype属性で「どんな処理が発生したのか」がわかるので条件分岐させてイベントハンドラーを記述します。

今回のソースでは
・count:前回の回数に+1
・input:入力された値をセット
の2つのステートを更新します。

そして、デフォルト引数では「どんなステートがセットされるのか」を宣言します。
これにより、アクションが初めて呼ばれる際のステートを決めることができます。

2.アクションクリエーターの作成

ストアーに引き渡すアクションを記述していきます。

action.jsx
export function setInput(input) {
  return {
    type: 'SET_INPUT',
    input: input
  }
}

アクションには
・type属性:どんな処理を行うのか?
・payload属性:どんな値が渡ってきたのか?
の2つを記述していきます。payload属性は任意ですが、type属性は必須です。

3.ストアーの作成

アプリ全体で一つのストアーを管理したいためルートコンポーネントであるApp.jsで2つのことを行います。
・ストアーの作成
・ストアーの引き渡し

App.jsx
import React,{ Component } from 'react';
import { BrowserRouter, Switch, Route } from 'react-router-dom';
import { createStore } from 'redux'
import reducer from './redux/reducer'
import { Provider } from 'react-redux';

//screens
import click from './redux/click-con';
import show from './redux/show-con';

// 1.ストアの作成
const store = createStore(reducer);

class App extends Component {
  render() {
    return (
      <BrowserRouter>
        {/* 2.ストアーの引き渡し */}
        <Provider store={store}>
          <Switch>
              <Route exact path="/click" component={click} />
              <Route exact path="/show" component={show} />
          </Switch>
        </Provider>
      </BrowserRouter>
    );
  }
}

export default App;

redux.createStoreメソッドに先ほど作ったreducerを渡して、ストアーを作成します。
このままだとストアーができただけで、コンポーネントにストアーが渡されていない状態です。
そのため、「react-redux」のProviderタグを使って各コンポーネントに渡します。

4.ストアーとコンポーネントの連携

connectメソッドを使ってストアーとコンポーネントを連携します。

click-con.jsx
import { connect } from 'react-redux'
import { setInput } from './action'
import click from '../screen/click'

const mapStateToProps = state => {
   const { input, count } = state
   return { input, count }
 }

 const mapDispatchToProps = dispatch => {
   return {
    setInputClick: (input) => { 
      dispatch(setInput(input));
      alert(`「${input}」がセットされました!`)
    }
   }
 }

export default connect(mapStateToProps,mapDispatchToProps)(click)
show-con.jsx
import { connect } from 'react-redux'
import show from '../screen/show'

const mapStateToProps = state => {
   const { input, count } = state
   return { input, count }
 }
export default connect(mapStateToProps)(show)

このconnectには主に2つの引数を渡します。
・mapStateToProps:必要なデータをストアーから取得
・mapDispatchToProps:ストアーにアクションを通知する関数

mapStateToProps

ストアーから渡されたステートをコンポーネントに渡します。
・第一引数:現在のステート
・戻り値:コンポーネントに渡すステート
また、ストアーのステートをそのまま渡すだけではなく、mapStateToProps内でデータを整形して渡すことができます。
そして、戻り値に設定したステートはコンポーネントの引数に追加されます。

mapDispatchToProps

コンポーネントにストアーに変更を通知するアクションクリエーターを渡します。
・第一引数:dispatchメソッド
・戻り値:ストアーに通知するアクションオブジェクト

dispatchメソッドにアクションオブジェクトを渡すことでストアーに通知できます。
mapStateToPropsと同じように戻り値に渡したオブジェクトはコンポーネントの引数に追加されます。

5.UIのイベントハンドラーの実装

最後にUIのイベントハンドラーにconnectメソッドで引き渡されたdispatchでラップされたアクションクリエータを呼び出します。

click.js
import React, { useState } from 'react';
import { Link } from 'react-router-dom'

export default (props) => {

  const style = {
    textAlign: "center",
    marginTop: "10px"
  };

  const [input, setInput] = useState(props.input);

  /**
   * inputを変更
   */
  const changeInput = (e) => {
    setInput(e.target.value);
  }

  return (
    <div style={style}>
      <input type="text" value={input} onChange={changeInput}></input>
      <button onClick={() => props.setInputClick(input)}>値セット</button>
      <br/><Link to="/show">表示へ</Link>
    </div>
  );
}
show.js
import React from 'react';
import { Link } from 'react-router-dom'

export default (props) => {
  const divStyle = {
    textAlign: "center",
    marginTop: "10px",
    "ul" : {
      listStyle: "none"
    }
  };

  const ulStyle = {
    listStyle: "none"
  }

  return (
    <div style={divStyle}>
      <ul style={ulStyle}>
        <li>カウント:{props.count}</li>
        <li>値:{props.input}</li>
      </ul>
      <Link to="/click">セットへ</Link>
    </div>

  );
}

QA

Q.全てのステートを渡すのはダメ?

A.全てステートをコンポーネントに渡すことは可能ですが、避けたほうが無難です。
コンポーネントに渡すステートが変更されたかどうかを判定してUIはリレンダリングするのかを判断しています。
そのため、全てのステートを渡すと無駄にリレンダリングされる可能性があります。
パフォーマンスを良くしたいのであれば、必要なステートだけをコンポーネントに渡すのが最善です。

ちなみに再処理される条件は以下の通りです。
・mapStateToProps ⇒ ステートが変更されたとき
・UIのリレンダリング ⇒ mapStateToPropsの戻り値が変更されたとき

また、redux内部では変更判定をシャロー比較(===)しているため、配列処理等(Array.filter、 Array.concat)でデータを再生成している際は注意が必要です。

参考

redux公式
react-redux公式
githubサンプル

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

Github Actionsでfirebaseに自動デプロイ(CI/CD)環境を構築する方法

はじめに

Github ActionsにてCI/CD環境を構築したのでメモ代わりにまとめてみました。
今回はGithub Actionsを用いたCI/CDが目的なのでfirebaseにデプロイするだけの場合は以下にわかりやすい記事のリンクを貼っておくので確認してみてください!!
・Firebaseで初めてのデプロイ

Github Actionsについて

Github Actionsとは

Githubが提供する機能の一部でCI(継続的インテグレーション)/ CD(継続的デリバリー)が実行できます。

CI/CDとは

テストや本番環境へのデプロイを自動化する仕組みのこと
image.png

個人的にGithub Actionsいいなって思ったところ

①レポジトリ配下にある.git/workflows/に.ymlファイルを作成して書くだけでGithub Actionsを使用できる!!
②github上のActionsからカスタマイズされたワークフローが選択でき、直接.ymlファイルを生成できる

Github Actionsの使い方

00.firebase認証トークンの設定

まずデプロイの際に必要になるfirebase認証トークンを設定していく

firebase login:ci

firebase CLIがGoogleアカウントへのアクセスをリクエストしてくるので、許可してトークンを控えておきます。
ログインすると以下のように出てくる

Waiting for authentication...

✔  Success! Use this token to login on a CI server:

xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

そのトークンをSecretsのFIREBASE_TOKENのvalueに入れて設定する
スクリーンショット 2020-09-22 22.55.59.png

01.Github Actionsにて非公開にしたい変数を登録して使用する

APIkeyや環境変数など晒したくない情報はGithubのSecretsに変数を登録することで公開されることなく利用することができます。
手順①SettingsからSecretsをクリック
スクリーンショット 2020-09-22 22.13.15.png
手順②Secretsに変数としてenvファイルのAPIkeyなどを追加していく
スクリーンショット 2020-09-22 22.20.12.png

02.YAMLファイルの作成とワークフローの実行

※Githubアカウントにログインしてレポジトリを作成できている上での説明になります
手順①作成したレポジトリのActionsにいき、New workflowをクリック
スクリーンショット 2020-09-22 15.04.27.png
手順②Set up this workflowからワークフローを作成!!
基本的にsuggestされているけどこのページの下にもさまざまなワークフローが用意されている。
スクリーンショット 2020-09-22 18.02.30.png
手順③レポジトリの.github/workflows/配下にYAMLファイルが作成されるのでカスタマイズ
ここでは、masterにpushされたら、 ubuntu-latest のdockerイメージをもちいて、 steps: 以降の処理を順次実行していってます。
steps: の中の uses: で使用するライブラリを指定、run: で実行するコマンドを記述していきます。

.github/workflows/deploy.yml
name: demo-app
on:
  push:
    branches:
      - master
jobs:
  firebase-deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@master
      - run: yarn install
      - run: yarn build
        env:
          FIREBASE_API_KEY: ${{ secrets.FIREBASE_API_KEY }}
          FIREBASE_AUTH_DOMAIN: ${{ secrets.FIREBASE_AUTH_DOMAIN }}
          FIREBASE_DATABASE_URL: ${{ secrets.FIREBASE_DATABASE_URL }}
          FIREBASE_PROJECT_ID: ${{ secrets.FIREBASE_PROJECT_ID }}
          FIREBASE_STORAGE_BUCKET: ${{ secrets.FIREBASE_STORAGE_BUCKET }}
          FIREBASE_MESSAGING_SENDER_ID: ${{ secrets.FIREBASE_MESSAGING_SENDER_ID }}
          FIREBASE_APP_ID: ${{ secrets.FIREBASE_APP_ID }}
          FIREBASE_MEASUREMENT_ID: ${{ secrets.FIREBASE_MEASUREMENT_ID }}
      - uses: w9jds/firebase-action@master
        with:
          args: deploy --only hosting
        env:
          FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}

envファイルを書く場所がわからなくてエラーから抜け出せなかった件について(´゚д゚`)
.github/workflows/deploy.yml
name: demo-app
on:
  push:
    branches:
      - master
jobs:
  firebase-deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@master
      - run: yarn install
      - run: yarn build
      - uses: w9jds/firebase-action@master
        with:
          args: deploy --only hosting
        env:
          FIREBASE_API_KEY: ${{ secrets.FIREBASE_API_KEY }}
          FIREBASE_AUTH_DOMAIN: ${{ secrets.FIREBASE_AUTH_DOMAIN }}
          FIREBASE_DATABASE_URL: ${{ secrets.FIREBASE_DATABASE_URL }}
          FIREBASE_PROJECT_ID: ${{ secrets.FIREBASE_PROJECT_ID }}
          FIREBASE_STORAGE_BUCKET: ${{ secrets.FIREBASE_STORAGE_BUCKET }}
          FIREBASE_MESSAGING_SENDER_ID: ${{ secrets.FIREBASE_MESSAGING_SENDER_ID }}
          FIREBASE_APP_ID: ${{ secrets.FIREBASE_APP_ID }}
          FIREBASE_MEASUREMENT_ID: ${{ secrets.FIREBASE_MEASUREMENT_ID }}
          FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}

最初は上記のようにenvを一番最後に記述していたところfirebaseのkeyがinvalidだよと怒られていました(汗)
keyとかはyarn buildした段階で設定しないといけないみたい。

03.envファイルに書いたkeyを環境変数に入れる

next.config.js
module.exports = {
  env: {
    FIREBASE_API_KEY: process.env.FIREBASE_API_KEY,
    FIREBASE_AUTH_DOMAIN: process.env.FIREBASE_AUTH_DOMAIN,
    FIREBASE_DATABASE_URL: process.env.FIREBASE_DATABASE_URL,
    FIREBASE_PROJECT_ID: process.env.FIREBASE_PROJECT_ID,
    FIREBASE_STORAGE_BUCKET: process.env.FIREBASE_STORAGE_BUCKET,
    FIREBASE_MESSAGING_SENDER_ID: process.env.FIREBASE_MESSAGING_SENDER_ID,
    FIREBASE_APP_ID: process.env.FIREBASE_APP_ID,
    FIREBASE_MEASUREMENT_ID: process.env.FIREBASE_MEASUREMENT_ID,
  },
};

process.env.NODE_ENV

envファイルの値を参照するにはprocess.env.で参照できます。

04.masterにプッシュしてデプロイされるのを確認してみる

スクリーンショット 2020-09-22 23.17.30.png
デプロイが成功できたのが確認できたら、プロジェクト名.web.appでページを開いて確認!!!
これで自動デプロイの完成です。

感想

CI/CD構築するということに難しそうという抵抗感があってなかなか進みませんでしたが、Gihub Actionsが便利すぎ!!!
ただ.ymlファイルに記述の仕方がいまいち理解できていなかったりAPIkeyを環境変数に入れて使用することに戸惑ったりしました。
これからもこんな感じで新しく学んだことはqiitaに軽くメモ程度に書いて投稿していこうと思いました。

参考文献

CI/CDとは???

https://pfs.nifcloud.com/navi/words/ci_cd.htm

Github Actionsについて

https://developer.yukimonkey.com/article/20200422/

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

React hooksの概要と種類のメモ

React hooksとは?

  • React16.8で追加された
  • classを作らなくてもstateやその他のReactの機能が使える
  • コンポーネントからReactの状態とライフサイクルの機能をフックする為の関数

なぜReact hooksを使う?

  • Reactには再利用可能な動作をコンポーネントにアタッチする方法を提供していない
    • render psopsなどを使っていたが、wrapper-hellに陥る
  • hooksはコンポーネントからstatefulなロジックを抽出し、独立させられる
    • 他のコンポーネントとstatefulなロジックを共有できる

FYI:hooksを導入する理由

hooksの種類

useState hook

https://reactjs.org/docs/hooks-reference.html#usestate

import React, { useState } from 'react'

const [pageNum, setPageNum] = useState(1)

ステートフルな値とそれを更新する関数を返す。
useState()に初期値を入れる。
stateを取得するにはpageNum、更新するにはsetPageNum(2)

useEffect hook

https://reactjs.org/docs/hooks-reference.html#useeffect

import React, { useEffect } from 'react'

useEffect(() => {
    // do something
}, [])

コンポーネントから副作用を実行する機能
副作用=データのフェッチや、手動でのDOM変更など
componentDidMount,componentDidUpdate,componentWillUnmountの代わり。
第二引数に値を入れると、その値に変更があった時のみ実行される

useContext hook

https://reactjs.org/docs/hooks-reference.html#usecontext

import React, { useContext } from 'react'

const theme = {
    dark: {
        background: '#000000'
    }
}
const ThemeContext = Reac.createContext(theme.dark)

const App = () => {
  return (
    <ThemeContext.Provider value={themes.dark}>
      <Button />
    </ThemeContext.Provider>
  );
}

const Button = () => {
const theme = useContext(ThemeContext);
    return (
        <button style={{ background: theme.background }} />
    )
}

情報を格納し、<MyContext.Provider>を介することで
任意のコンポーネントと情報を共有できる。バケツリレーをせずに済むReduxのようなもの。

Redux vs React Context

useContext & useReducerでReduxと同じことを実現できる。
Reduxを使うには多くのライブラリが必要だが、
useContext & useReducerなら実装が簡単で、バンドルサイズも増加しない。
しかし、Contextは更新の度に再レンダリングがかかる為、更新頻度の多いものには向いていない。
FYI:Redux VS React Context: Which one should you choose?

useReducer hook

https://reactjs.org/docs/hooks-reference.html#usereducer

const initialState = {count: 0};

const reducer = (state, action) => {
    switch (action.type) {
        case 'increment':
            return {count: state.count + 1};
        case 'decrement':
            return {count: state.count - 1};
        default:
            throw new Error();
    }
}

const Counter = () => {
    const [state, dispatch] = useReducer(reducer, initialState);

    return (
        <>
            <button onClick={() => dispatch({type: 'decrement'})}>-</button>
            <button onClick={() => dispatch({type: 'increment'})}>+</button>
        </>
    )
}

useStateの代替で、より複雑な状態管理に使われる。
action typeに紐づくstateを返す。
useContextと一緒に使うとReduxと同じことができる。

useCallback hook

https://reactjs.org/docs/hooks-reference.html#usecallback

import React, { useCallback } from 'react';

const memoizedCallback = useCallback(() => {
    doSomething(a, b);
  }, [a, b],
);

stateやpropsが更新される度に関数で使われているインスタンスも再作成されるが、
メモ化されたコールバック関数を返すことで
子コンポーネントの無駄な再レンダリングを防ぐもの。
第二引数に依存関係にある値を渡し、その値に変更がある度にメモ化された関数を返す。
FYI:How to use React useCallback hook with examples

useMemo hook

https://reactjs.org/docs/hooks-reference.html#usememo

import React, { useMemo } from 'react';

const memorizedValue = useMemo(() => {
    return (a * b) / 2;
}, [a, b]);

useCallbackとは違い、メモ化された"値"を返す。
関数の呼び出し間およびレンダリング間の計算結果をメモする。
FYI:Demystifying React Hooks: useCallback and useMemo

useRef hook

https://reactjs.org/docs/hooks-reference.html#useref

import React, { useRef } from 'react';

const targetRef = useRef(null)
// targetRef = { current: null}

ほとんどのDOMにref属性があり、useRefを使用してHTML内の要素を参照できる。
(あるボタンをクリックしたらinputにfocusしたり、スクロールさせたり、etc...)
またstateやpropsに変更があると再レンダリングがトリガーされるが、useRefはされない。
FYI:Demystifying React Hooks: useRef

custom hook

https://reactjs.org/docs/hooks-overview.html#building-your-own-hooks

import React, { useState, useEffect } from 'react';

const useFriendStatus = (friendID) => {
    const [isOnline, setIsOnline] = useState(null);

    useEffect(() => {
        ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange);
        return () => {
            ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange);
        }
    }, [])
    return isOnline
}
const friendStatus = useFriendStatus(friendId)

コンポーネント間でステートフルなロジックを再利用したい場合に使う。
ツリーにコンポーネントを追加する必要なし。
ステートフルなロジックを再利用するものであり、ステート自体を再利用するものではない。

others

hooksのルール

  • ループ、条件、ネストされた関数の中でフックを呼び出さないこと
  • フックはReactのコンポーネントから呼び出すこと(カスタムフックは除く)
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む