- 投稿日:2020-09-23T22:47:21+09:00
local環境でReactをhttpsで立てる(WSL)
はじめに
wslのlocal環境でreactをhttpsで立てる際、少し特殊な設定が必要だったので備忘録として作業手順を残しておきます。
SSL証明書の発行まで
こちらの記事を参考に作業を進めます。(ルート証明書のインストールまで)
https://qiita.com/recordare/items/d51f50dc634187e20538httpsで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にアクセスできる!!
- 投稿日:2020-09-23T21:58:46+09:00
【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というものでできそう。
- 投稿日:2020-09-23T21:44:14+09:00
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
へアクセス不要な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.tsximport 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.tsximport React from "react"; function App() { return <div></div>; } export default App;まずはダミーのデータを用いて描画処理
今回はAppコンポーネントに直書きします。
App.tsximport 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」を選択 -> 有効にする -> 完了
データベースが作成できたら、任意のデータを挿入
コレクションはテーブルみたいなもので、ドキュメントはレコードみたいなものです。上記では、ドキュメントIDにランダム文字列を使用しています。
Firestoreとアプリケーションの連携
まず、Reactアプリケーションに以下のSDKを導入
npm install firebase
次に
./src
にfirebase.ts
を作成し、以下の内容を記述firebase.tsimport 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にてアプリを追加したのちに設定/全般ページの下の方で取得できます。firebaseConfig.tsexport const firebaseConfig = { apiKey: "", authDomain: "", databaseURL: "", projectId: "", storageBucket: "", messagingSenderId: "", appId: "", };
firebaseConfig.ts
は認証情報が含まれるので、公開してはいけません。.gitignore
に含めておきましょう。.gitignore... # firebase firebaseConfig.ts一覧表示を実装
まず、連携が無事に出来ているかを確認
App.tsximport 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;ページを表示し、連携がうまくいっていたらデータがそのまま表示されるはずです。
取得したデータを
setUsers()
を利用して state に代入App.tsximport 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;追加、削除、更新の実装
削除処理
削除したタイミングでデータを取得し直すように実装しました。それと、一応のエラー処理も実装してみました。
App.tsxfunction 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> ); } ...追加処理
App.tsxfunction 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);最後に
短期間の開発ではこれでいいかもしれませんが、そうでない場合は共通化・モジュール化などしましょう!
リンク
- 投稿日:2020-09-23T12:20:45+09:00
React + TypeScript + vte.cxで簡単なWebアプリを作ってみた③ 条件検索機能編
はじめに
今まで作ったアプリケーションに今回は登録されているデータを条件によって検索できる機能を実装していきました。
今回作ったアプリケーション
一つの画面の中に条件フォームを作り、絞りこみボタンを押すとその条件に合致した情報を画面上に表示する機能です。
例えば以下の画像では「好きな住居形態」という項目で「森」を選んだ結果、合致するゴリラとhatakeyamaが画面に表示されました。
前回のページネーションがちゃんと作動しているのでページネーション番号もちゃんと表示されています。
また絞る条件は一項目ではなく複数項目選ぶことができます。今回使用するコンポーネント
条件検索のロジックを持ったUserInfoFilterコンポーネントと条件検索で帰ってきた値を格納するUserInfoコンポーネントを今回使っていきます。
UserInfoFilter.tsx(条件検索するコンポーネント)
UserInfoFilter.tsximport * 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コンポーネントの中のボタンに絞りこみという名前を渡しています。
ちなみにこの部分です。
この絞りこみボタンを押すと、入力されたデータを親コンポーネント(UserInfo.tsx)に渡します。
入力された後にsearchDataメソッドが発火します。
UserInfoFilter.tsxconst 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.tsxprops.click(passParams,res.data.feed.subtitle)の部分で親に項目のデータと総件数(res.data.feed.subtitleで総件数が帰ってきます。)を渡しています。
UserInfo.tsx(UserInfoFilterで貼ったインデックスをもとにデータをとってくるコンポーネント)
UserInfo.tsximport * 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 UserInfoUserInfoコンポーネントの子コンポーネントであるUserInfoFilter.tsxから渡される情報を引数として受け取り、
setSearchConditions
で値を格納します。UserInfo.tsxconst 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.tsxif (searchConditions) { await axios.get( '/d/users?' + searchParams + 'n=' + page + '&l=' + displayPage) } else { await axios.get(`/d/users?n=${page}&l=${displayPage}`) }という部分があり、ページネーションをする際に毎回searchConditionsに値があれば条件検索をされたページネーションする流れになっています。
なければ通常通りの条件なしでのページネーションします。まとめ
①UserInfoFilterでインデックスを貼る
②検索条件があればUserInfoIndexで貼ったインデックスを参照してページネーションする
といった流れで条件検索をすることができました。
- 投稿日:2020-09-23T10:29:06+09:00
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.jsimport { 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.jsimport 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■コンテクストの
Provider
のvalue
に参照するオブジェクトを与えるsrc/App.jsimport 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■コンテクストを使ったカウンター
Unstated Nextでカウンターをつくり替える
カスタムフックとコンテクストがわかりましたので、カウンターをUnstated Nextで動くようにつくり直して見ましょう。コンテクストに替えて、コンテナをつくります。
カスタムフックからコンテナをつくる
モジュール
src/useCounter.js
のカスタムフックは基本的に変わりません。フックを関数createContainer()
でコンテナに包むのです。コンテナ(CounterContainer
)は、いわばカスタムフック(useCounter
)のロジックを備えたコンテクストといえます。src/useCounter.jsimport { 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.jsimport { 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.jsimport 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.jsimport 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でカウンターをつくる
- 投稿日:2020-09-23T08:48:15+09:00
綺麗な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があります。
- 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.tsximport 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>childrenのpropsの拡張</h1> <LoggerChildrenProps log="with children props"> <LogTextButton /> ほげ </LoggerChildrenProps> <h1>componentのprops渡し</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.tsximport 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.tsximport 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.tsximport 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.tsxconst 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.tsximport 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.tsximport 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.tsimport { 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.tsximport 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作成したショーケースが閲覧できます。
Visual Regression Test(表示回帰テスト)
storybookにショーケースを作成しておくことで表示のデグレが起きていないかテスト(特にMaterial-UIなどのUI系のライブラリを導入している場合はライブラリバージョンを上げた際の確認用にやっておいたほうが良い)
storybook公式ではChromaticを推しています。
GitHubなどのリポジトリ単位で連携しかつstorybookの全ショーケースに対して
Visual Regression Testを行ってくれます。$ yarn add --dev chromatic $ npx chromatic --project-token {Chromaticのトークン}以下はfontSize変更した際に検出された表示差分です。
Github連携するとIntegrationsにChromaticが追加されます。
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項目に設定している想定です。設定完了するとGithub Actionsが動くようになります。
Github連携が完了しているとBranch protection rulesにUI ReviewとUI Testsが現れます。
これらの項目をPR merge条件として必須化させることもできます。Chromaticは5000 snapshot/monthまで無料で、個人用プロジェクトレベルならあまり問題ないですが、
料金が気になるという場合はBackStopJSライブラリなどを使って自前でVisual Regression Testをする方法もあります。(その際、比較元の環境が必要ですが・・・)
- 投稿日:2020-09-23T07:54:01+09:00
Firestoreでリアルタイムな無限スクロールを実現する
動機
FirestoreのSnapshotListenerを使えばリアルタイムチャットのような機能を比較的容易に作成することができます。しかし一方で、特別な制御を行わなければメッセージを一度に購読してしまい、場合によってはデータ量が非常に大きくなってしまいます。これを避けるために、無限スクロールをSnapshotListenerを使いつつ実現する方法に挑戦しました。
制作したもの
ReactとFirestoreを使って無限スクロールが行える簡易Chatアプリを作ってみました
使用技術
期待動作
Chatアプリでは次の動作を満たすものとします。
また、今回の前提として購読中メッセージは物理的に削除されないこととします。
- 初回ロード時に直近一定数のChatメッセージを表示する
- 新しいメッセージが追加されたら、リアルタイムにChatウィンドウに追加する
- 上向きにスクロールすることで過去のメッセージを一定数ずつ追加する
- メッセージが編集された場合、リアルタイムに表示を更新する
実現方法
この機能を実現するために、Firestoreの制御とスクロールの制御に分けて解説します
Firestoreの制御
メッセージを購読するためにSnapshotListenerを登録します。無限スクロールにおけるSnapshotListenerは大きく、未来/過去の2種類の購読方法によって実現しています。
このような制御を分ける理由としては、購読するメッセージ数が増加する可能性のある「未来」のメッセージに対してlimit
関数を適用しないためです。この理由については以下の記事を参照ください。1. 未来(最新メッセージ)の購読リスナー
新しく投稿されたメッセージを受け取るためのリスナーを登録します。
startAfter
を用いて、現在時刻以降の全ての新規メッセージを購読しています。const db = firebase.firestore(); const now = Date.now(); ... // 未来(最新メッセージ)のSnapshotListener登録 db.collection('messages') .orderBy('date','asc') // field 'date'は数値とする .startAfter(now) .onSnapshot((snapshot)=>{ ...// データ取得 })2.過去メッセージの購読リスナー
初期表示及び、スクロール時に追加読み込みするためのリスナーを登録します。
orderBy
とstartAfter
を用いて、現時刻より前のメッセージを新しい順に購読しています。また、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)) : false2. スクロール上端検知と追加購読
スクロール領域の上端や下端を検知した時に、追加読み込みするためのライブラリはすでに公開されているものが多く、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を使った無限スクロールについて、今回は全体の流れ中心に記載しましたが、実際には使い勝手を向上させるために新着メッセージの自動スクロールダウンなどの詳細制御等も必要になってくるかと思います。
- 投稿日:2020-09-23T06:30:46+09:00
Reactでローカルストレージとstateを連携させる例とNext.jsの注意点
はじめに
Reactで以下のようにローカルストレージとstateを同期させる場合のサンプルを掲載します。
初回のstate:
- ローカルストレージに値が保存されていたらその値を使う
- そうでなければ任意の初期値を与える
stateの更新時:
- state更新に用いた値をローカルストレージに保存する
ツールはcreate-react-app(以下CRA)とNext.jsの2種類になります。
両者ともに余計なコンポーネントや既存のCSSは消去して確認しています。
なおCRAとNext.jsの基礎的なセットアップ方法と仕組みについては省略します。サンプルはライトテーマとダークテーマの切替を想定しています。
これを動かすと以下のような画面になります。ちなみにローカルストレージの動きはChromeのApplicationタブ等で確認できます。
記事を書いた背景
言語切替機能を付けたWebサイトをNext.jsで実装していたのですが、Next.jsで少しハマる部分がありました。
機能概要は、初回は日本語で読み込み、日本語か英語か選択した後は次回読み込み時にその状態が保存されるものです。詳細はstateによりreact-helmetでhtmlタグのlang属性を弄り、また言語切替用Contextの値に代入する仕組み(※)ですが、Context等は本題でないので省略して簡素な例を掲載します。
※ 参考にしたページ:https://ja.reactjs.org/docs/context.html#dynamic-context
その際にローカルストレージ周りについて少しコードを書いて整理したノウハウを記録しておきたいと思いました。
CRA
theme
ステートの初期値はローカルストレージのtheme
キーがあればその値を、無ければ'light'
を代入します。- ボタンの押下により
theme
ステートに'light'
または'dark'
を代入し、ローカルストレージにもその値をセットします。Styled text
のスタイルはtheme
ステートが'light'
か'dark'
かにより変わります。App.jsimport 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.jsimport 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.jsexport default function Home() { return <div></div>; }これには多少の工夫が必要です。
どうすれば良いのかというと、
theme
の初期値はundefined
を代入しておきます。
そしてuseEffect
(もしくはcomponentDidMount
)の内部でローカルストレージによる条件分岐を書きます。実際のコード例は以下のようになります。
_app.jsimport 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.jsexport default function Home() { return <div></div>; }これで正常に表示され、一件落着です。
蛇足
調べると
componentDidMount
やuseEffect
内でstateを更新することはアンチパターンという情報がありますが、どの程度厳密に守るべきなのか分かりません。筆者はまだReactについて知識が曖昧な点が沢山あるので優しい方はご教示頂けると嬉しいです。
- 投稿日:2020-09-23T06:30:46+09:00
Reactでローカルストレージとstateを連携させるサンプル
はじめに
Reactで以下のようにローカルストレージとstateを同期させる場合のサンプルを掲載します。
初回のstate:
- ローカルストレージに値が保存されていたらその値を使う
- そうでなければ任意の初期値を与える
stateの更新時:
- state更新に用いた値をローカルストレージに保存する
ツールはcreate-react-app(以下CRA)とNext.jsの2種類になります。
両者ともに余計なコンポーネントや既存のCSSは消去して確認しています。
なおCRAとNext.jsの基礎的なセットアップ方法と仕組みについては省略します。サンプルはライトテーマとダークテーマの切替を想定しています。
これを動かすと以下のような画面になります。ちなみにローカルストレージの動きはChromeのApplicationタブ等で確認できます。
記事を書いた背景
言語切替機能を付けたWebサイトをNext.jsで実装していたのですが、Next.jsで少しハマる部分がありました。
機能概要は、初回は日本語で読み込み、日本語か英語か選択した後は次回読み込み時にその状態が保存されるものです。詳細はstateによりreact-helmetでhtmlタグのlang属性を弄り、また言語切替用Contextの値に代入する仕組みですが、Context等は本題でないので省略して簡素な例を掲載します。
その際にローカルストレージ周りについて少しコードを書いて整理したノウハウを記録しておきたいと思いました。
CRA
theme
ステートの初期値はローカルストレージのtheme
キーがあればその値を、無ければ'light'
を代入します。- ボタンの押下により
theme
ステートに'light'
または'dark'
を代入し、ローカルストレージにもその値をセットします。Styled text
のスタイルはtheme
ステートが'light'
か'dark'
かにより変わります。App.jsimport 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.jsimport 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.jsexport default function Home() { return <div></div>; }これには多少の工夫が必要です。
どうすれば良いのかというと、
theme
の初期値はundefined
を代入しておきます。
そしてuseEffect
(もしくはcomponentDidMount
)の内部でローカルストレージによる条件分岐を書きます。実際のコード例は以下のようになります。
_app.jsimport 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.jsexport default function Home() { return <div></div>; }これで正常に表示され、一件落着です。
蛇足
調べると
componentDidMount
やuseEffect
内でstateを更新することはアンチパターンという情報がありますが、どの程度厳密に守るべきなのか分かりません。筆者はまだReactについて知識が曖昧な点が沢山あるので優しい方はご教示頂けると嬉しいです。
- 投稿日:2020-09-23T06:12:17+09:00
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.jsxexport function setInput(input) { return { type: 'SET_INPUT', input: input } }アクションには
・type属性:どんな処理を行うのか?
・payload属性:どんな値が渡ってきたのか?
の2つを記述していきます。payload属性は任意ですが、type属性は必須です。3.ストアーの作成
アプリ全体で一つのストアーを管理したいためルートコンポーネントであるApp.jsで2つのことを行います。
・ストアーの作成
・ストアーの引き渡しApp.jsximport 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.jsximport { 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.jsximport { 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.jsimport 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.jsimport 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)でデータを再生成している際は注意が必要です。
参考
- 投稿日:2020-09-23T06:12:17+09:00
【図解】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.jsxexport function setInput(input) { return { type: 'SET_INPUT', input: input } }アクションには
・type属性:どんな処理を行うのか?
・payload属性:どんな値が渡ってきたのか?
の2つを記述していきます。payload属性は任意ですが、type属性は必須です。3.ストアーの作成
アプリ全体で一つのストアーを管理したいためルートコンポーネントであるApp.jsで2つのことを行います。
・ストアーの作成
・ストアーの引き渡しApp.jsximport 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.jsximport { 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.jsximport { 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.jsimport 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.jsimport 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)でデータを再生成している際は注意が必要です。
参考
- 投稿日:2020-09-23T01:36:59+09:00
Github Actionsでfirebaseに自動デプロイ(CI/CD)環境を構築する方法
はじめに
Github ActionsにてCI/CD環境を構築したのでメモ代わりにまとめてみました。
今回はGithub Actionsを用いたCI/CDが目的なのでfirebaseにデプロイするだけの場合は以下にわかりやすい記事のリンクを貼っておくので確認してみてください!!
・Firebaseで初めてのデプロイGithub Actionsについて
Github Actionsとは
Githubが提供する機能の一部でCI(継続的インテグレーション)/ CD(継続的デリバリー)が実行できます。
CI/CDとは
個人的にGithub Actionsいいなって思ったところ
①レポジトリ配下にある.git/workflows/に.ymlファイルを作成して書くだけでGithub Actionsを使用できる!!
②github上のActionsからカスタマイズされたワークフローが選択でき、直接.ymlファイルを生成できるGithub Actionsの使い方
00.firebase認証トークンの設定
まずデプロイの際に必要になるfirebase認証トークンを設定していく
firebase login:cifirebase CLIがGoogleアカウントへのアクセスをリクエストしてくるので、許可してトークンを控えておきます。
ログインすると以下のように出てくるWaiting for authentication... ✔ Success! Use this token to login on a CI server: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxそのトークンをSecretsの
FIREBASE_TOKEN
のvalueに入れて設定する
01.Github Actionsにて非公開にしたい変数を登録して使用する
APIkeyや環境変数など晒したくない情報はGithubのSecretsに変数を登録することで公開されることなく利用することができます。
手順①SettingsからSecretsをクリック
手順②Secretsに変数としてenvファイルのAPIkeyなどを追加していく
02.YAMLファイルの作成とワークフローの実行
※Githubアカウントにログインしてレポジトリを作成できている上での説明になります
手順①作成したレポジトリのActionsにいき、New workflowをクリック
手順②Set up this workflowからワークフローを作成!!
基本的にsuggestされているけどこのページの下にもさまざまなワークフローが用意されている。
手順③レポジトリの.github/workflows/
配下にYAMLファイルが作成されるのでカスタマイズ
ここでは、masterにpushされたら、 ubuntu-latest のdockerイメージをもちいて、 steps: 以降の処理を順次実行していってます。
steps: の中の uses: で使用するライブラリを指定、run: で実行するコマンドを記述していきます。.github/workflows/deploy.ymlname: 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.ymlname: 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.jsmodule.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_ENVenvファイルの値を参照するには
process.env.
で参照できます。04.masterにプッシュしてデプロイされるのを確認してみる
デプロイが成功できたのが確認できたら、プロジェクト名.web.appでページを開いて確認!!!
これで自動デプロイの完成です。感想
CI/CD構築するということに難しそうという抵抗感があってなかなか進みませんでしたが、Gihub Actionsが便利すぎ!!!
ただ.ymlファイルに記述の仕方がいまいち理解できていなかったりAPIkeyを環境変数に入れて使用することに戸惑ったりしました。
これからもこんな感じで新しく学んだことはqiitaに軽くメモ程度に書いて投稿していこうと思いました。参考文献
CI/CDとは???
Github Actionsについて
- 投稿日:2020-09-23T00:34:44+09:00
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 examplesuseMemo 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 useMemouseRef 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: useRefcustom 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のコンポーネントから呼び出すこと(カスタムフックは除く)