- 投稿日:2019-12-22T23:29:29+09:00
PlantUMLのマインドマップを用いてRedux-SagaとRedux Storeについて考えた話
これは React Advent Calendar 2019 の22日目の記事です。
はじめに
先日、「TwitterやSlackのRedux Storeを覗く」という記事が話題になっていました。そこでも述べられているように、Reduxを用いたアプリケーションではStoreの設計が重要になってきます。筆者も、最近友人とReact(Redux+ReduxSaga)+FirebaseのWebアプリを開発する中で Storeの構成についてや、Redux-Sagaを呼び出すタイミングの整理をしたシーンがありました。その際、PlantUMLのマインドマップを用いたところ、スッキリと情報を整理することができました。この記事では、どういった経緯でマインドマップを利用することになったのか、またどのように活用したのかということを書いていきます。
特定のSagaを呼び出す適切なタイミングを考える必要があった
ReduxとRedux-Sagaを用いて開発を進める中で課題に感じていたことが「様々にあるSagaはどのタイミングで飛ばすべきなのか、どのタイミングで飛ばさなければいけないのか」ということです。またそれに関連して「画面をレンダリングする時に必要なstateが存在しないという状況を無くしたい」ということも感じていました。
何がいけなかったのか
上述した課題に直面した原因は「stateの依存関係およびそのstateを取得するSagaを飛ばすタイミングを考慮していなかったこと」です。この状況の例えとして、Twitterのようなフォローユーザー一覧画面やタイムライン画面を持つアプリを想定して考えてみます。
上の図の中では、ホーム画面、フォローユーザー一覧画面、タイムライン画面の三つの画面があります。そして、それぞれの画面がそれぞれの画面の描写に必要な
state
を持っています。ここでは、フォローユーザー一覧画面をレンダリングするタイミングでSagaを飛ばしてstateC(フォローユーザー情報)
をFirestoreに問い合わせています。一方で、タイムライン画面はstateD(タイムライン情報)
を持っているのですが、これはstateC(フォローユーザー情報)
に依存しているstateになります。フォローしているユーザーの情報が無ければタイムラインを表示できないからです。ここにstateの依存関係があるのですが、これを考慮していなかったためフォローユーザー一覧画面を経由してからでないとタイムラインが正常にレンダリングできなかったり、タイムライン画面をレンダリングするタイミングでもう一度フォローユーザー情報を取得するSagaを飛ばさなければいけないという冗長な作りになっていました。こういった問題が浮き彫りになったため、情報を整理するためPlantUMLのマインドマップを用いて考えてみます。
マインドマップを用いて整理する
前述した状況をマインドマップを用いて表現したのが以下の図です。
先ほどの流れで、まずは画面ベースでstateを考えてみます。このマインドマップでは各画面が持つ
state
を末端に表しています。ここで、Sagaについて考える上での観点が二つあります。一つ目は、state A
がホーム画面とフォローユーザー一覧画面で重複していること、二つ目はタイムライン画面においてstate D(タイムライン情報)
がstate C(フォローユーザ情報)
に依存していることです。まずは一つ目について考えます。
state A
に関して、それぞれの画面をレンダリングする時に別々のSagaによって取得することは冗長です。なので、state A
を取得するSagaは1回だけにして、認証後の初期外部リクエスト(initial saga)によって該当データを取得することにします。次に二つ目についてです。
state D(タイムライン情報)
はstate C(フォローユーザ情報)
に依存しています。つまり、state C
が無ければstate D
も表示させることができません。そこで、プロフィール画面もしくはタイムライン画面個別に紐づいたSagaではなく、一つのSagaとしてstate C
を取得することにします。こちらも認証後の初期外部リクエスト(initial saga)で飛ばすのが良さそうです。ここまでPlantUMLのマインドマップを用いてstateとSagaについて考えてみました。ここで、stateとSagaについてまとめてみます。下図のように、二つの画面で重複していた
state A
と、依存関係にあったstate C
はまとめて認証後の初期外部リクエスト(initial saga)によってデータを取得するようにしました。また、Storeに関しては下図のような構成になっています。最初は画面ベースでstateを考えていましたが、
state A
が複数の画面で重複しているため冗長な構成を避けたいこと、開発規模が小さいことから最終的には画面ベースではなくドメインベースの構成になっています。これらに加えて、開発中のアプリのビューは完全に決定してるわけではないので、後々のビューの変更にも強いドメインベースを採用しています。画面ごとに分担作業するなどの場合は冗長でも画面ベースで考えるというのはありかもしれません。ちなみに、Redux公式ドキュメントはドメインベースでのStore設計を推奨しています。また、Store設計に関しては「TwitterやSlackのRedux Storeを覗く」の記事が勉強になりました。おわりに
今回、マインドマップを用いてSagaの呼び出し順序やStoreの構成を考えてみました。ReduxやRedux-Sagaを使用し複雑なビジネスロジックになる場合、整理のためにマインドマップを用いてみてはどうでしょうか。また、Storeの設計を考える上で「TwitterやSlackのRedux Storeを覗く」の記事はとても参考になると思いました。
- 投稿日:2019-12-22T23:29:29+09:00
マインドマップを用いてRedux-sagaとstoreの構成を整理した話
これは React Advent Calendar 2019 の22日目の記事です。
はじめに
先日、「TwitterやSlackのRedux Storeを覗く」という記事が話題になっていました。そこでも述べられているように、Reduxを用いたアプリケーションではStoreの設計が重要になってきます。筆者も、最近友人とReact(Redux+ReduxSaga)+FirebaseのWebアプリを開発する中で Storeの構成についてや、Redux-Sagaを呼び出すタイミングの整理をしたシーンがありました。その際、PlantUMLのマインドマップを用いたところ、スッキリと情報を整理することができました。この記事では、どういった経緯でマインドマップを利用することになったのか、またどのように活用したのかということを書いていきます。
特定のSagaを呼び出す適切なタイミングを考える必要があった
ReduxとRedux-Sagaを用いて開発を進める中で課題に感じていたことが「様々にあるSagaはどのタイミングで飛ばすべきなのか、どのタイミングで飛ばさなければいけないのか」ということです。またそれに関連して「画面をレンダリングする時に必要なstateが存在しないという状況を無くしたい」ということも感じていました。
何がいけなかったのか
上述した課題に直面した原因は「stateの依存関係およびそのstateを取得するSagaを飛ばすタイミングを考慮していなかったこと」です。この状況の例えとして、Twitterのようなフォローユーザー一覧画面やタイムライン画面を持つアプリを想定して考えてみます。
上の図の中では、ホーム画面、フォローユーザー一覧画面、タイムライン画面の三つの画面があります。そして、それぞれの画面がそれぞれの画面の描写に必要な
state
を持っています。ここでは、フォローユーザー一覧画面をレンダリングするタイミングでSagaを飛ばしてstateC(フォローユーザー情報)
をFirestoreに問い合わせています。一方で、タイムライン画面はstateD(タイムライン情報)
を持っているのですが、これはstateC(フォローユーザー情報)
に依存しているstateになります。フォローしているユーザーの情報が無ければタイムラインを表示できないからです。ここにstateの依存関係があるのですが、これを考慮していなかったためフォローユーザー一覧画面を経由してからでないとタイムラインが正常にレンダリングできなかったり、タイムライン画面をレンダリングするタイミングでもう一度フォローユーザー情報を取得するSagaを飛ばさなければいけないという冗長な作りになっていました。こういった問題が浮き彫りになったため、情報を整理するためPlantUMLのマインドマップを用いて考えてみます。
マインドマップを用いて整理する
前述した状況をマインドマップを用いて表現したのが以下の図です。
先ほどの流れで、まずは画面ベースでstateを考えてみます。このマインドマップでは各画面が持つ
state
を末端に表しています。ここで、Sagaについて考える上での観点が二つあります。一つ目は、state A
がホーム画面とフォローユーザー一覧画面で重複していること、二つ目はタイムライン画面においてstate D(タイムライン情報)
がstate C(フォローユーザ情報)
に依存していることです。まずは一つ目について考えます。
state A
に関して、それぞれの画面をレンダリングする時に別々のSagaによって取得することは冗長です。なので、state A
を取得するSagaは1回だけにして、認証後の初期外部リクエスト(initial saga)によって該当データを取得することにします。次に二つ目についてです。
state D(タイムライン情報)
はstate C(フォローユーザ情報)
に依存しています。つまり、state C
が無ければstate D
も表示させることができません。そこで、プロフィール画面もしくはタイムライン画面個別に紐づいたSagaではなく、一つのSagaとしてstate C
を取得することにします。こちらも認証後の初期外部リクエスト(initial saga)で飛ばすのが良さそうです。ここまでPlantUMLのマインドマップを用いてstateとSagaについて考えてみました。ここで、stateとSagaについてまとめてみます。下図のように、二つの画面で重複していた
state A
と、依存関係にあったstate C
はまとめて認証後の初期外部リクエスト(initial saga)によってデータを取得するようにしました。また、Storeに関しては下図のような構成になっています。最初は画面ベースでstateを考えていましたが、
state A
が複数の画面で重複しているため冗長な構成を避けたいこと、開発規模が小さいことから最終的には画面ベースではなくドメインベースの構成になっています。これらに加えて、開発中のアプリのビューは完全に決定してるわけではないので、後々のビューの変更にも強いドメインベースを採用しています。画面ごとに分担作業するなどの場合は冗長でも画面ベースで考えるというのはありかもしれません。ちなみに、Redux公式ドキュメントはドメインベースでのStore設計を推奨しています。また、Store設計に関しては「TwitterやSlackのRedux Storeを覗く」の記事が勉強になりました。おわりに
今回、マインドマップを用いてSagaの呼び出し順序やStoreの構成を考えてみました。ReduxやRedux-Sagaを使用し複雑なビジネスロジックになる場合、整理のためにマインドマップを用いてみてはどうでしょうか。また、Storeの設計を考える上で「TwitterやSlackのRedux Storeを覗く」の記事はとても参考になると思いました。
- 投稿日:2019-12-22T23:11:29+09:00
React Hooks ✖️ Reduxを使ってみて 自分なりに詰まったところをまとめてみた
自分の作っているサービスで初めてReact Hooks, reduxを使用したので自分が詰まったところをまとめてみました!
この記事はReact Hooks, reduxを使う上で、自分が詰まったところについて書いています。
それぞれ個々の説明に関しては公式のドキュメントや別記事をお読みください参考にさせていただいた記事
Redux Hooks によるラクラク dispatch & states
React Hooksでredux / react-reduxでやってたことを色々やってみる
?React 16.8: 正式版となったReact Hooksを今さら総ざらいするこの記事の対象読者
- 私のようなReact超初心者
- React Hooks, redux それぞれは何となくわかったけど一緒に使う方法がわからない方
私の開発環境
- macOS Mojave 10.14.6
- Node.js 12.13.0
- React 16.12.0
- create-react-appでアプリの雛形を作成
今回作るもの
- ボタンを押したら足したり引いたりできものを作ります
actionを作る
src/actions/action.js
にactionを定義します。
今回は足したり引いたりするだけなのでこの二つでいいでしょう。action.jsexport const incrementAction = () => ({ type: 'INCREMENT', }); export const decrementAction = () => ({ type: 'DECREMENT', });reducerを作る
src/reducers/counterReducer.js
にreducersを定義します。
今回は、combineReducersを使いrootReducerでそれぞれのreducerをまとめる形にしました。rootReducer.jsconst rootReducer = combineReducers({ counterReducer, }); export default rootReducercounterReducer.jsconst initialState = { counter: 0, }; export default function counterReducer(state = initialState, action) { switch (action.type) { case "INCREMENT": return {counter: state.counter + 1} case "DECREMENT": return {counter: state.counter - 1} } return {counter: state.counter} }この時注意しないといけない点が、returnする際に連想配列の形にすることです!
私はstate + 1
やstate.counter + 1
としていたため、うまく受け渡しができていませんでしたstoreを作る
react-create-app
で雛形を作った場合src/index.js
があるのでそこに書いていきましょう!
ReactDOM.renderしているファイルでcreateStoreにrootReducerを渡します。
作成したstoreをProviderに渡して下のコンポーネントでも使えるようにします。index.jsconst store = createStore(rootReducer); ReactDOM.render( <Provider store={store}> <App/> </Provider>, document.getElementById('root') );呼び出す
index.js
で呼び出しているコンポーネントで上で定義したものを呼び出していきます。
react-create-app
で雛形を作った場合src/App.js
です!App.jsexport default function App() { const dispatch = useDispatch() const counter = useSelector(state => state.counterReducer.counter);// 使用するreducerを引数に渡す。 return ( <div className="App"> <p> count: {counter} </p> <button onClick={() => dispatch(incrementAction())}>たす</button> <button onClick={() => dispatch(decrementAction())}>ひく</button> </div> ); }この時注意しないといけない点が、useSelectorに引数を渡す際ちゃんとreducerを指定して渡すことです!
reducerをrootでまとめているのでuseSelector(state => state.counter)
の様にしてしまうと当たり前ですがundefind
が返ってきてしまいます。以上で完了です、ページにアクセスしてみましょう。
まとめ
今回はじめてReact Hooksを使ってみたんですがすごく便利ですね!
まだ出たばかりで資料もすくなかったため、僕の様な超初心者でもわかるように僕が詰まったりしたところを丁寧に書いてみました。Reactを書くのは初めてなので、この書き方はおかしい!こっちの方がいい!みたいなご指摘がありましたらぜひコメント等いただければと思います。
- 投稿日:2019-12-22T23:03:03+09:00
Webpackで出力したbundleのハッシュ値を抽出して使う
個人的に開発しているサイトではWordPressのPHP上にReactを載せており、そこではbundleのハッシュ値をPHP側で参照できるようにしています。extract-hash-webpack-pluginを使うと任意のテキスト形式でハッシュ値を含んだファイルを出力できます。今回はPHPから参照したいので
.php
の形式で出力してみます。以下がwebpack.config.js
の例です。var path = require('path'); var ExtractHashWebpackPlugin = require('extract-hash-webpack-plugin').default; const { CleanWebpackPlugin } = require('clean-webpack-plugin'); const DIST_PATH = "theme/dist"; module.exports = { entry: "./src/index.tsx", output: { filename: "index.[hash].js", path: path.resolve(__dirname, DIST_PATH) }, /* ... */ plugins: [ new ExtractHashWebpackPlugin({ dest: DIST_PATH, filename: 'version.php', fn: hash => `<? $JS_HASH = '${hash}'; ?>` }), /* ... */ ] };プラグインの
fn
オプションでハッシュ値を使った出力を定義できます。今回は変数$JS_HASH
にハッシュ値を保存するPHPのコードを出力するようにしています。
- 投稿日:2019-12-22T23:00:21+09:00
「zeit/swr」のpagination exampleでScroll Position Restoreを試す
こちらはNext.js Advent Calendar 2019の22日目の記事です。
はじめに
例えばYoutubeをスマホで見ていたときに、↓のように一度ページ遷移をしてからブラウザバックしたときにスクロール位置が一番上まで戻ってしまって経験はないでしょうか。
これはSPAが抱える課題の1つで、解決するためには「Scroll Position Restore」と呼ばれる「スクロール位置の保持」とその「リストア(復元)」が必要です。
また、それ以外にも「もっと読む」などで画面内に非同期で要素が追加された場合はその要素も合わせて復元しないと正しいスクロール位置には戻れません。これには複雑なStateの管理も必要になってきます。
このように「Scroll Position Restore」を実現するにはいくつか超えなくてはいけない壁があります。
zeit/swr の登場
2019年10月、 Next.jsやNowの開発元であるZeitから「zeit/swr」というライブラリが発表されました。
そして、この「zeit/swr」の機能の1つにある「Pagination」が「Scroll Position Restore」を実現するための機能となっています!swr(Stale-While-Revalidate) とは主にキャッシュ周りの用語なのでこのライブラリもキャッシュ周りのハンドリングも含むdata fetchのReact Hooksライブラリとなっています。1
簡単にやってくれることを書くと、「最初にキャッシュした古いデータを見せておいて、その裏で最新のデータをfetchしてキャッシュと差し替える」といった感じかなと思います。2
早速、Paginationを動かしてみる
Next.jsもそうですが、リポジトリ内にexamplesというサンプル集があり、そこにPaginationのexampleもあったので今回はそれを動かしてみます。
swr/examples/pagination at master · zeit/swr
READMEに書いてあるとおり、Setupを終わらせます(このサンプル自体もNext.jsが使われていました)。
$ curl https://codeload.github.com/zeit/swr/tar.gz/master | tar -xz --strip=2 swr-master/examples/pagination $ cd pagination $ yarn $ yarn dev起動したサーバーにアクセスすると以下のページが出てきます。「load more」で読み込ん要素が、ページ遷移をしてブラウザバックをしても表示されていることがわかると思います!また、スクロール位置も保持されていそうですね。3
実装はこんな感じです。
import fetch from '../libs/fetch' import Link from 'next/link' import useSWR, { useSWRPages } from 'swr' export default () => { const { pages, isLoadingMore, isReachingEnd, loadMore } = useSWRPages( // page key 'demo-page', // page component ({ offset, withSWR }) => { const { data: projects } = withSWR( // use the wrapper to wrap the *pagination API SWR* useSWR('/api/projects?offset=' + (offset || 0), fetch) ) // you can still use other SWRs outside if (!projects) { return <p>loading</p> } return projects.map(project => <p key={project.id}>{project.name}</p> ) }, // one page's SWR => offset of next page ({ data: projects }) => { return projects && projects.length ? projects[projects.length - 1].id + 1 : null }, // deps of the page component [] ) return <div> <h1>Pagination (offset from data)</h1> {pages} <button onClick={loadMore} disabled={isReachingEnd || isLoadingMore}> {isLoadingMore ? '. . .' : isReachingEnd ? 'no more data' : 'load more'} </button> <hr /> <Link href="/page-index"><a>page index based pagination →</a></Link><br/> <Link href="/about"><a>go to another page →</a></Link> </div> }
useSWRPages()
の引数として、キャッシュキーとしても使われるページ名、実際にfetchしたデータを使って生成するComponent、次ページのoffsetなどを設定しています。ここの説明はコード側にシーケンス図があったのでそちらを見るとより分かりやすいかなと思います。
少し触ってみたりissueなども見たところ、まだスクロール位置の復元がやや不完全であったり、キャッシュのストアを触ろうとしたときに不自由さはあるものの、シンプルなアプリケーションであれば比較的簡単に利用できそうな印象でした。
さいごに
zeit/swrはまだまだ発表されたばかりで利用実績も多くありませんが、頻繁に開発されており、何より開発元がzeitであるため今後ますますNext.jsとうまく協調して進化していくはずです。Nextをお使いの方はぜひその動向に注目してみてください!
以上、22日目の記事でした!
- 投稿日:2019-12-22T20:56:00+09:00
React-hook-formで簡単にバリデーションフォーム作る
Reactでフォームの実装をしたことのある、もしくはこれから実装する皆さん。
React-hook-formをご存知ですか?
フォームの実装がとても楽になる便利なライブラリです。この記事ではReact-hook-formの基本的な簡単な使い方と
実装例をソースコードとともに解説しています。React-hook-formとは?
高性能で柔軟かつ拡張可能な使いやすいフォームバリデーションライブラリ。(引用)
従来のformライブラリに比べて、以下の特徴があります。1
・記述量が少ない
・レンダリングが少ない
・マウントが高速
・hooksで記述がシンプルそして何より。。
バリデーションの実装が楽になります。使い方
それではReact-hook-formの簡単な使い方を見てみましょう。
以下は公式デモのソースコードです。import React from 'react' import useForm from 'react-hook-form' export default function App() { const { register, handleSubmit, watch, errors } = useForm() const onSubmit = data => { console.log(data) } console.log(watch('example')) return ( <form onSubmit={handleSubmit(onSubmit)}> <input name="example" defaultValue="test" ref={register} /> <input name="exampleRequired" ref={register({ required: true })} /> {errors.exampleRequired && <span>This field is required</span>} <input type="submit" /> </form> ) }React-hook-formでは必要なメソッドやオブジェクトをuseFormから受け取って使用します。
以下の手順で実装します。1. フィールドを登録する。
非制御コンポーネント (Uncontrolled Components) をフックに登録(register) し、フォームフィールドの値を検証と収集できるようにする(引用)登録したいフィールドに
name="uniqueName"
とref={register}
を加えます。<input name="example" defaultValue="test" ref={register} />2. バリデーションとエラー文言を設定する。
registerメソッドにバリデーションを渡し、
バリデーション時にエラーが発生するとerrorsオブジェクトに
先ほど加えたnameをkeyとしたエラーメッセージを割り当てられます。2<input name="exampleRequired" ref={register({ required: true })} /> {errors.exampleRequired && <span>This field is required</span>上記の例の
required
はバリデーションの際に必須入力を求めます。
バリデーションは上記の他に最大文字数、最小文字数なども設定でき、
さらに正規表現やバリデーション関数を渡すこともできます!実装例
では実際に以下のようなフォームを実装してみます。
・各フォームごとに入力後バリデーションする
・バリデーションエラーの場合はエラーメッセージを表示する
・全てのフォームが正しく入力されている場合のみsubmitボタンを押せるようにするhooksのみで実装してみると。。。(長いので読む必要なし)
import * as React from 'react'; interface FormData { title: string; author: string; } interface FormValidationResults { title: boolean; author: boolean; } interface ErrorMessage { title: string; author: string; } const SomeForms: React.FC = () => { const [values, setValues] = React.useState<FormData>({ title: '', author: '' }); const [validationResults, setValidationResults] = React.useState< FormValidationResults >({ title: false, author: false }); const [errorMessages, setErrorMessages] = React.useState<ErrorMessage>({ title: '', author: '' }); const handleChange = (name: keyof FormData) => ( event: React.ChangeEvent<HTMLTextAreaElement> ) => { const newValues = { ...values, [name]: event.target.value }; setValues(newValues); validate(newValues, name); }; const validate = (values: FormData, name: keyof FormValidationResults) => { switch (name) { case 'title': titleValidation(values[name]); break; case 'author': authorValidation(values[name]); break; } }; const titleValidation = (value: string): void => { if (value.length < 1 || value.length > 20) { setValidationResults({ ...validationResults, title: false }); setErrorMessages({ ...errorMessages, title: 'タイトル名は1文字以上、20文字以下でなければなりません。' }); } else { setValidationResults({ ...validationResults, title: true }); setErrorMessages({ ...errorMessages, title: '' }); } }; const authorValidation = (value: string): void => { if (value.length < 1 || value.length > 20) { setValidationResults({ ...validationResults, author: false }); setErrorMessages({ ...errorMessages, author: '作者名は1文字以上、20文字以下でなければなりません。' }); } else { setValidationResults({ ...validationResults, author: true }); setErrorMessages({ ...errorMessages, author: '' }); } }; return ( <div> <h2>タイトル名</h2> <textarea name='title' value={values.title} onChange={handleChange('title')} /> {errorMessages.title && <span>{errorMessages.title}</span>} <h2>作者名</h2> <textarea name='author' value={values.author} onChange={handleChange('author')} /> {errorMessages.author && <span>{errorMessages.author}</span>} <button disabled={ validationResults.title && validationResults.author ? false : true } > 送信する </button> </div> ); }; export default SomeForms;。。。長い。。改行があるとはいえ100行強あります。
useStateで以下を管理しています。。。長い。
・フィールドの値
・エラーメッセージ
・バリデーションがvalidかどうかReact-hook-formで実装
import * as React from 'react'; import useForm from 'react-hook-form'; interface FormData { title: string; author: string; } const OtherForms: React.FC<{}> = () => { const { register, handleSubmit, errors, formState } = useForm<FormData>({ mode: 'onChange' }); const onSubmit = (data: FormData): void => console.log(data); return ( <div> <h2>タイトル名</h2> <form onSubmit={handleSubmit(onSubmit)}> <textarea name='title' ref={register({ required: true, maxLength: 20 })} /> {errors.title && '作者名は1文字以上、20文字以下でなければなりません。'} <h2>作者名</h2> <textarea name='author' ref={register({ required: true, maxLength: 20 })} /> {errors.author && '作者名は1文字以上、20文字以下でなければなりません。'} <button disabled={!formState.isValid}>送信する</button> </form> </div> ); }; export default OtherForms;なんと37行!(しかもフォームの結果をconsole.logで出力している)
デモの実装例にはなかった2つのメソッドorオブジェクトを追加して実装しています。・formState
フォームの状態に関する情報が含まれているオブジェクト。
formState.isValidはフィールドにエラーがない状態かどうかをbooleanで表しています。・handleSubmit
バリデーションに成功するとフォームのデータを渡してくれるメソッド。補足
バリデーションのタイミングはオプションで指定することができます。
今回は各フォームの入力ごとにバリデーションしたいので、
useFormに{mode: 'onChange'}
を渡しています。
パフォーマンスの観点ではレンダリングが増えるので推奨されてはいないようです。
バリデーションのみの登録とバリデーションとエラーメッセージをセットで登録することもできます。https://react-hook-form.com/jp/api/#register ↩
- 投稿日:2019-12-22T18:50:38+09:00
【python】flaskを使ってローカルサーバーを動かしてみた
はじめに
Progateでしかpythonを触ったことのない筆者が、pythonを使ってローカルサーバーを立てて、任意のアドレス対して動作をさせるまでの備忘録。
開発環境
Windows 10 64bit
Python 3.7.3
pip 19.3.1
flask 1.1.1環境構築
pythonをインストールしていない人はまずはインストールをします!
詳しい方法はこちらからインストールした記憶がないけどあるかも...と思う方はコマンドプロンプトを開いて、
python -V
で確認してみましょう!$ python --version Python 3.7.3 $ python -V Python 3.7.3インストール済みであれば以上のように表示されます!
インストールが完了したら、Pythonのパッケージ管理ツールであるpipをインストールしていきます!
インストール済みか確認したい方はpython -m pip -Vで確認できます!
インストールされていなかった人は詳しいインストール方法はこちら以下のコマンドを叩いてインストールしていきます!
py -m pip install pypdf2インストールができたら、今回使うpythonの軽量フレームワークであるflaskのインストールを行っていきます!
flask使ったことのある人はこの記事を読まないであろうと想定して、インストールの仕方から!pip install Flask確認したいときは
$ python >> import flask >> flask.__version__(\$マークはコピペしないでね。てかこの\$って何なんでしょう?)
ここまで出来たらとりあえずの環境構築は終了です。
おつかれさまでした。さっそく実装
今回は前々回の記事で作成したreact-appでボタンをクリックしたときに指定したデータを返すような実装をしていきます。
前々回の記事→【React】はじめてReact触ってみた!~create-react-app編~
よかったらいいねしてください()それはさておき、コードを書いていきます。
前回作ったもののフォルダとの相関関係はこんな感じです!Sample └ resource └ react-test └ node_modules └ public └ src └ App.js ・ ・ ・ └ flask-test └ run.pyまずは、いろいろなサイトを参考にpython側のコードを作成しました。
run.pyimport json # Flask などの必要なライブラリをインポートする from flask import Flask from flask_cors import CORS from flask import request, make_response, jsonify # 自身の名称を app という名前でインスタンス化する app = Flask(__name__) # CORS (Cross-Origin Resource Sharing) CORS(app) # ここからウェブアプリケーション用のルーティングを記述 # index にアクセスしたときの処理 @app.route('/', methods=['GET']) def show_user(): response = {'user': {'name': 'index', 'age': 'hoge', 'job': 'web'}} return make_response(jsonify(response)) # /user にアクセスしたときの処理 @app.route('/user', methods=['GET']) def show_user2(): response = {'user': {'name': 'user', 'age': 'fuga', 'job': 'free'}} return make_response(jsonify(response)) if __name__ == '__main__': app.run()↓ 参考にした記事の数々はこちら ↓
・ウェブアプリケーションフレームワーク Flask を使ってみる
・PythonのFlaskを使用してWebアプリ作成してみよう(1)こんにちは世界
・Flaskのユーザーガイド続いて、以前create-react-appを使って作成したアプリを改造していきます!
react-test > App.js を開き、以下のようにした。App.jsimport React from 'react'; import logo from './logo.svg'; import './App.css'; import Axios from 'axios'; function App() { function click(){ Axios.get('http://127.0.0.1:5000/').then(function(res){ console.log(res); alert(res.data.user.age); }).catch(function(e){ alert(e); }) } return ( <div className="App"> <header className="App-header"> <img src={logo} className="App-logo" alt="logo" /> <p> Edit <code>src/App.js</code> and save to reload. </p> <a className="App-link" href="https://reactjs.org" target="_blank" rel="noopener noreferrer" > Learn React <br/> </a> <button onClick={() => {click()}}>click</button> </header> </div> ); } export default App;やっとこさ実行
ここまで来たらあとはコマンドプロンプトで動かすだけ!
早速コマンドプロンプトを開いて、cd C:\Users\{user}\Documents\Sample\flask-testで、指定ディレクトリに移動します。
ここで、python run.pyを起動!
C:\Users\{user}\Documents\Sample\flask-test>python run.py * Serving Flask app "run" (lazy loading) * Environment: production WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead. * Debug mode: off * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)となれば成功!!
続いて、コマンドプロンプトを別ウィンドウでも開き、
cd C:\Users\{user}\Documents\Sample\react-testでcreate-react-appのディレクトリに移動します。
そして前々回と同様にC:\Users\{user}\Documents\Sample\react-test>npm startで起動!これでうご...かない!?
Module not found: Can't resolve 'axios' ・・・axiosのモジュールがないとか...
たしかに、インポートするaxiosのモジュールインストールした記憶ない...ってことで早速以下のコマンドを叩いてインストールします!C:\Users\{user}\Documents\Sample\react-test>npm install axiosこんどこそいけるはず!ってことでもう一度
C:\Users\{user}\Documents\Sample\react-test>npm start...
C:\Users\{user}\Documents\Sample\react-test>npm start npm WARN lifecycle The node binary used for scripts is C:\Program Files (x86)\Nodist\bin\node.exe but npm is using C:\Program Files (x86)\Nodist\v-x64\10.15.3\node.exe itself. Use the `--scripts-prepend-node-path` option to include the path for the node binary npm was executed with. > test@0.1.0 start C:\Users\sleep\Documents\React-Tutorial\test > react-scripts start Setting NODE_PATH to resolve modules absolutely has been deprecated in favor of setting baseUrl in jsconfig.json (or tsconfig.json if you are using TypeScript) and will be removed in a future major release of create-react-app. Starting the development server... Compiled successfully! You can now view test in the browser. Local: http://localhost:3000/ On Your Network: http://192.168.10.101:3000/ Note that the development build is not optimized. To create a production build, use npm run build.きた~~~~~~~~!
これでclickボタンを押すと...
hogeが返ってきた!!成功!!
次にclickしたときに飛ぶアドレスを変える!
App.jsfunction click(){ Axios.get('http://127.0.0.1:5000/').then(function(res){ console.log(res); alert(res.data.user.age); }).catch(function(e){ alert(e); }) }これを
App.jsfunction click(){ Axios.get('http://127.0.0.1:5000/user').then(function(res){ console.log(res); alert(res.data.user.age); }).catch(function(e){ alert(e); }) }こうして(アドレスの末尾にuserを追加)クリックボタンを押すと、
fugaに変わった!!!とりあえず想定通り動かせました、よかった~。
ちょっと躓くところもありましたが、環境構築さえできてしまえば何とかなりそうです!おわりに
最後まで見てくださった方がいましたら、本当にありがとうございます。
初歩の初歩らしいところからやってみましたが、動かせるだけでめちゃくちゃうれしいものですね。
もっと改良してまたまとめていきたいと思います!
- 投稿日:2019-12-22T18:22:41+09:00
Reactで投稿のIDを取得する
はじめに
いなたつアドカレの二十二日目の記事です。
もうすぐ終わりですね。。。
今回はReactでURLから投稿などのIDを取得する方法の備忘録です。
じっそー
App.jsimport React from 'react'; import { BrowserRouter as Router, Route } from 'react-router-dom' function App() { return ( <Router> <Route exact path='/:id' component={Hoge} /> </Router> ); } export default App;
/:id
のURLでHogeのコンポーネントというRouteの定義ができました。const id = props.match.params.idHogeのコンポーネントで上のようにすることで、idを使用できます
このidを使ってAPiを叩くなりしましょ
- 投稿日:2019-12-22T18:09:51+09:00
オレオレredux 型まわりベストプラクティスを書いてみたい
Redux+TSを書くときに個人的によくやるパターンを書いてみます
頑張って前日に書きました
はじめに
Reduxの流れ自体は単方向でグルグル回る感じになっています 画像引用元
ReduxのTSに関しては単方向ではなく、Actionの型情報をComponentとReducerに渡す必要があり、ちょっと都合が違います。
これをある程度楽する方法を書いていきます
書くもの概要
ユーザーの一覧の表示・追加・削除できる画面
以下ソース
model
モデル// ユーザーのモデル interface IUser { id: number name: string email: string age: number }Action
action// アクションのTypeのあれ export enum UserActions { ADD = 'user/ADD', REMOVE = 'user/REMOVE', } // アクションの一覧 export const userActions = { add: (payload: {user: User}) => ({ type: UserActions.ADD as const, payload, }), remove: (payload: {userId: User['id']}) => ({ type: UserActions.REMOVE as const, payload, }), } // 以下UtilityType // MapをUnionとしてとりだす type MapToUnion<T> = T[keyof T]; interface Action { type: string; } interface Actions { [key: string]: (...args: any) => Action; } // userActionsをUnionとして取り出すよう type ActionToUnion<T extends Actions> = Extract< ReturnType<MapToUnion<T>>, Action >; // userActionsを {[k: アクション名]: アクションの戻り値} にするやつ type ActionToMap<T extends Actions> = { [P in keyof T]: ReturnType<T[P]> }; export type UserReducerAction = ActionToMap<typeof userActions>; export type UserReducerActions = ActionToUnion< typeof userActions >;Component
componentinterface mapStateToProps { users: User[] } interface dispatchProps { // ここで作った型を指定 add: (payload: UserReducerAction['add']['payload']) => void remove: (payload: UserReducerAction['remove']['payload']) => void } type Props = mapStateToProps & dispatchProps // コンポーネントに関しては特に書かない const _UserPage: FC<Props> = ({users, addUser, removeUser}) => ( <UserList // ユーザー一覧を受け取って表示 users={users} // 作成時のイベント onCreate={(user: User) => addUser({user})} // 削除時のイベント onRemove={(user: User) => {removeUser({userId: user.id})}} /> ) // ストアのイメージ interface Store { user: { users: User[] } } const mapStateToProps =(state: Store): mapStateToProps => ({ users: state.user.users, }) const mapDispatchToProps = (dispatch: Dispatch<UserReducerActions>): dispatchProps => ({ addUser: payload => dispatch(userActions.addUser(payload)), removeUser: payload => dispatch(userActions.removeUser(payload)), }) }) export const UserPage = connect( mapStateToProps, mapDispatchToProps )(_UserPage);Reducer
reducerinterface UserState { users: User[] } const initialState: UserState = { users: [], } // UserReducerActions これでActionの型を指定する export const userReducer = (state = initialState, action: UserReducerActions): UserState => { switch (action.type) { case UserActions.ADD: { return { ...state, users: state.users.concat(action.payload.user) } } case UserActions.REMOVE: { const index = state.users.findIndex(v => v.id === action.payload.userId) if (index === -1) { return state } return { ...state, users: state.users.splice(index, 1); } } default: return state } }解説
肝は
- アクションをオブジェクトにまとめる https://qiita.com/eretica/private/81e8567756a0f98a3393#action
-ActionToMap
とActionToUnion
を利用して扱いやすい型にする
- 以降はComponentやReducerで適宜利用する
- redux-thunk や redux-saga などでもconnectの部分以外は同じようなパターンで利用できる。使う際はdispatch(userActions.add())
で利用可能UtilyTypeで使いやすい形にすることで、楽できるよ!ってのが伝わると嬉しい
interface dispatchProps { addUser: (payload: UserReducerAction['add']['payload']) => void removeUser: (payload: UserReducerAction['remove']['payload']) => void }よりみち
payload: Action['hoge']['payload']
が冗長であればinterface dispatchProps { addUser: (payload: UserReducerAction['add']['payload']) => void removeUser: (payload: UserReducerAction['remove']['payload']) => void }こういうUtilityTypeをつくればもう少し楽できます
type ArgumentTypes<F extends Function> = F extends (args: infer A) => any ? (arg: A) => void : never; type ActionToArguments<T extends Actions> = { [P in keyof T]: ArgumentTypes<T[P]>}; export type UserReducerActionArgument = ActionToArguments<typeof userActions>; interface dispatchProps { addUser: UserReducerActionArgument['add'] removeUser: UserReducerActionArgument['remove'] }まとめ
- actionを使いやすい型にしておくことで、楽をしたい
よりみち2
こちらの記事(なぜカスタムフックを作るのか)のように、custom hooks が状態やアクションを提供する形になればコネクトの部分の型定義は不要になるので、さらに楽できるのではないかと思っています。
個人的にはこのようなcustom hooksはcontainerコンポーネントに相当するものだけが利用(use***)し、従来通りのコンポーネント設計に留めて置くのが吉なのかなとは思っています
- 投稿日:2019-12-22T17:36:01+09:00
Reactコンポーネントライブラリ解発用のボイラープレートを作った
react-component-library-boilerplate
このテンプレートには以下のものが含まれています
- react本体
- rollup
- typescript
- example demo pages build by
react-styleguidist
- CSS Modules
- your any css files budled to build file
- eslint
- prettier
- husky
- jest and @testing-library/react
- build scripts
- include your license and dependencies license to
dist
directory- github actions
- プッシュされる度にgithub actions上でビルドタスクを実行します
usage
1. install
$ git clone https://github.com/aclearworld/react-component-library-boilerplate.git <your-library-name> $ cd <your-library-name> $ rm .git/ -rf $ npm ci2. edit
LICENSE
,package.json
,README.md
以下のものをあなたが望むように編集して下さい
- edit
LICENSE
- edit
package.json
fileds
- name
- description
- private
- repository
- keywords
- author
- license
- bugs
- homepage
- edit
README.md
3. start devlopment
$ npm run start
4. enjoy your hacking!!
- 投稿日:2019-12-22T17:36:01+09:00
Reactコンポーネントライブラリ開発用のボイラープレートを作った
react-component-library-boilerplate
このテンプレートには以下のものが含まれています
- react本体
- rollup
- typescript
- example demo pages build by
react-styleguidist
- CSS Modules
- your any css files budled to build file
- eslint
- prettier
- husky
- jest and @testing-library/react
- build scripts
- include your license and dependencies license to
dist
directory- github actions
- プッシュされる度にgithub actions上でビルドタスクを実行します
usage
1. install
$ git clone https://github.com/aclearworld/react-component-library-boilerplate.git <your-library-name> $ cd <your-library-name> $ rm .git/ -rf $ npm ci2. edit
LICENSE
,package.json
,README.md
以下のものをあなたが望むように編集して下さい
- edit
LICENSE
- edit
package.json
fileds
- name
- description
- private
- repository
- keywords
- author
- license
- bugs
- homepage
- edit
README.md
3. start devlopment
$ npm run start
4. enjoy your hacking!!
- 投稿日:2019-12-22T12:02:25+09:00
【Atomic Design入門】Todoアプリを作りながらAtomic Designを理解する
はじめに
こんにちは、masa08です。普段はエンジニア限定シェアハウス「GAOGAO-TOKYO」で過ごしつつ、スタートアップで業務委託エンジニアをしつつ、大学生してます(早く卒業したい)。
今年は多くの新しい技術に触れた年でした。特にフロントエンドに関しては知見を得たことが多かったです。去年の今ごろはどうにかしてJavaScriptがかけるくらいの実力だったのですが、今年一年通じて、TypeScriptやReact、Atomic Designと様々な技術や考え方に出会い、実務を通じて自分の理解を深めることができました。
今回の記事ではアウトプットも兼ねて、Atomic Designに沿ったディレクトリ構成でReact×TypeScriptを用いてTodoアプリを作っていきたいと思います。自分自身わからないことも多いので、何かあればご指摘いただけると幸いです。対象者
- JavaScriptの基本的な知識を持っている
- Reactの基本的な知識を持っている
- Atomicデザインに触れたいと考えている
- TypeScriptに触れたいと考えている
この記事を通じて得られること
- Reactでアプリケーションを作る方法
- TypeScriptでコードを書く経験
- Atomic Designに関する知識
Atomic Designとは
画像引用元サイト: Atomic Design MethodologyAtomic Designとは、Webサイト上に存在するものすべてをコンポーネントとみなし、粒度ごとにコンポーネントの種類を分けて管理していく、コンポーネントベースの開発手法のことです。小さいコンポーネントを組み合わせて、より大きなコンポーネントを作っていきます。以下の5つの粒度に分けて、コンポーネントを大別します。この際、上位層は下位層に依存します。
- Atoms(原子)
- Molecules(分子)
- Organisms(有機体)
- Templates(テンプレート)
- Pages(ページ)
Atoms
Atoms層は、それ以上UIとして分解できない最小要素のことです。テキストやボタンが代表的な要素です。
Molecules
Molecules層は、2つ以上ののAtomsが組み合わされて作られたコンポーネントです。検索フォームなどが代表的な要素です。
Organisms
Organisms層はMoleculesやAtomsで構成されるコンポーネント群です。Headerなどが代表的な要素です。
Templates/Pages
Template層はページの雛形、Pages層は実際のページ、すなわちTemplate層にコンテンツを流し込んだものになります。ここにきてユーザーから見えるページが完成します。organisms層を中心として、Templates/Pagesを組み立てていきます。
Atomic Designを採用するメリット
従来の開発手法と比べて、コンポーネントベースでのUI開発には以下の利点があります。
- アプリケーションのメンテナンスしやすくなる
- 解決する問題が小さくなる(責務の分離)
- チーム内で共通認識を持つことができる
- コンポーネントの再利用、平行開発等にによって、開発のスピードが速くなる
実際にコードを書いて、Atomic Designの雰囲気を感じてみましょう。
参考サイト
Atomic Design について調べて見た
Atomic Designを分かったつもりになる
React, Components, and DesignTodoアプリを作る
Atomic Designがどのようなものか、atomsとmolecules、organisms、pagesを作る過程を追いながら確認していきます。
環境を整える
まず最初にnodeをインストールしましょう。以下のサイトからダウンロードします。
https://nodejs.org/ja/download/
ダウンロードしたファイルを開いて、nodeをインストールします。インストールが終わったら、ターミナルで以下のコマンドを打って、nodeがインストールされているのかを確認しましょう。またnodeをインストールするとnpmを一緒にインストールされるので、そちらも確認しましょう。$ node -v # vxx.xx.xのような形で表示されれば正解 $ npm -v # vx.x.xのような形で表示されれば正解プロジェクトを作成する
プロジェクトを作成しましょう。ターミナルを開き、以下のコマンドを実行しましょう。
$ cd path/to/your/directory $ npx create-react-app sample-atomic --template typescriptsample-atomicディレクトリが作成されていることを確認したら、以下のコマンドを実行して、アプリケーションを立ち上げましょう。
$ cd sample-atomic $ npm startAtomic Designを導入する
必要なフォルダを作成し、routingのためのライブラリをインストールします。
$ mkdir src/components $ mkdir src/components/atoms $ mkdir src/components/molecules $ mkdir src/components/organisms $ mkdir src/components/pages $ touch src/components/pages/Home.tsx $ npm install @types/react-router-domApp.tsxを以下のように編集します。
App.tsximport React from 'react'; import { BrowserRouter, Route } from 'react-router-dom'; import './App.css'; import Home from './components/pages/Home'; const App: React.FC = () => { return ( <BrowserRouter> <Route exact path='/' component={Home}></Route> </BrowserRouter> ); } export default App;material-uiをインストールしてからHome.tsxを編集します。
$ npm i @material-ui/core
Home.tsximport React, { useState } from "react"; import List from '@material-ui/core/List' import ListItem from '@material-ui/core/ListItem' const Home: React.FC = () => { const [todos, setTodos] = useState<string[]>(["test1", "test2", "test3"]); const [value, setvalue] = useState<string>(""); const onChange = (e: React.ChangeEvent<HTMLInputElement>): void => { const value = e.target.value; setvalue(value); }; const onSubmit = (e: React.FormEvent<HTMLFormElement>): void => { e.preventDefault(); setTodos([...todos.concat(value)]); }; return ( <div> <header> <h1>This is Header</h1> </header> <List> {todos.map(todo => ( <ListItem key={todo}>{todo}</ListItem> ))} </List> <form onSubmit={onSubmit}> <input type='text' onChange={onChange} /> <input type='submit' value='submit' /> </form> </div> ); }; export default Home;以下のような画面が出てくれば正解です。
このアプリケーション内にある要素をAtomic Designに沿って分解していきます。上記をorganismsの粒度のコンポーネントに分けると、
- Header
- List
- Form
となり、その中でさらにmoleculesとatomsに分解していきます。結果的に、src/componentsは以下のようなディレクトリ構成になりました(templateは今回作っていません)。
├── components │ ├── atoms │ │ └── FormInput.tsx │ ├── molecules │ │ └── Form.tsx │ ├── organisms │ │ ├── FormWrapper.tsx │ │ ├── Header.tsx │ │ └── TodoList.tsx └── └── pages └── Home.tsxHome.tsximport React, { useEffect, useState } from "react"; import Header from '../organisms/Header'; import TodoList from "../organisms/TodoList"; import FormWrapper from "../organisms/FormWrapper"; const Home: React.FC = () => { const [todos, setTodos] = useState<string[]>(["test1", "test2", "test3"]); const [value, setvalue] = useState<string>(""); const onChange = (e: React.ChangeEvent<HTMLInputElement>): void => { const value = e.target.value; setvalue(value); }; const onSubmit = (e: React.FormEvent<HTMLFormElement>): void => { e.preventDefault(); setTodos([...todos.concat(value)]); }; return ( <div> <Header text='This is Header' /> <TodoList todos={todos} /> <FormWrapper onChange={onChange} onSubmit={onSubmit} /> </div> ); }; export default Home;organisms/Header.tsximport React from "react"; interface IProps { text: string; } const Header: React.FC<IProps> = ({text}) => { return ( <header> <h1>{text}</h1> </header> ); }; export default Header;organisms/TodoList.tsximport React from "react"; import List from '@material-ui/core/List' import ListItem from '@material-ui/core/ListItem' interface IProps { todos: string[]; } const TodoList: React.FC<IProps> = ({todos}) => { return ( <List> {todos.map(todo => ( <ListItem key={todo}>{todo}</ListItem> ))} </List> ) } export default TodoList;organisms/FormWrapper.tsximport React from "react"; import Form from "../molecules/Form" import FormInput from "../atoms/FormInput" interface IProps { onSubmit: (e: React.FormEvent<HTMLFormElement>) => void; onChange: (e: React.ChangeEvent<HTMLInputElement>) => void; } const FormWrapper: React.FC<IProps> = ({onSubmit, onChange}) => { return ( <Form onSubmit={onSubmit}> <FormInput onChange={onChange} /> <input type='submit' value='submit' /> </Form> ); }; export default FormWrapper;molecules/Form.tsximport React from "react"; interface IProps { onSubmit: (e: React.FormEvent<HTMLFormElement>) => void; } const Form: React.FC<IProps> = ({ children, onSubmit }) => { return <form onSubmit={onSubmit}>{children}</form>; }; export default Form;atoms/FormInput.tsximport React from "react"; interface IProps { onChange: (e: React.ChangeEvent<HTMLInputElement>) => void; } const FormInput: React.FC<IProps> = ({ onChange }) => { return <input type='text' onChange={onChange} />; }; export default FormInput;コンポーネント分解し終えた後に、Todoアプリが通常通り動けば成功です!
終わりに
今回のように非常に小さなアプリケーションでは、Atomic Designを採用するメリットは見えづらいですが、実際に実務で使う場合は先述のメリットを享受することができます。今回の記事で、Atomic Designという考え方と、大まかな雰囲気を知っていただければ幸いです。
Atomic DesignはReactと相性が良く、今後も使われていくデザインの概念だと思います。ReactやVueなどのコンポーネント指向のライブラリを使う場合は、是非一緒に採用してみてください。
- 投稿日:2019-12-22T10:23:10+09:00
intersection observerを使用したInfinite Scrollの実装
この記事は CyberAgent 20 新卒 Advent Calendar 2019 の 22 日目です!
今回はタイトルにも書いてありますがintersection observerを使用してInfinite Scrollを実装したいと思います。Intersection Observerとは
Intersection Observer API (交差監視 API) は、ターゲットとなる要素が、祖先要素もしくは文書の最上位のビューポートと交差する変更を非同期的に監視する方法を提供します。
https://developer.mozilla.org/ja/docs/Web/API/Intersection_Observer_APIuseObserver
- intersection observerのcustom hook
import React, { useEffect, useState, RefObject } from 'react'; function useObserver( ref: RefObject<HTMLDivElement>, ops?: IntersectionObserverInit ) { const [intersect, setIntersect] = useState<boolean>(false); useEffect(() => { function callback(entries: IntersectionObserverEntry[]) { entries.forEach(entry => { setIntersect(entry.isIntersecting); }); } const observer = new IntersectionObserver(callback, ops); if (ref.current === null) { return; } observer.observe(ref.current); return () => { if (ref.current !== null) { observer.unobserve(ref.current); } }; }); return intersect; }使い方
export const Index: React.FC = () => { const target = React.useRef<HTMLElement>(null); const intersect = useObserver(target); return ( <React.Fragment> <div style={{ position: 'fixed', top: 0, left: '50%' }}> {intersect ? 'visible' : 'not visible'} </div> <div style={{ height: 1000 }}></div> <div ref={target}></div> </React.Fragment> ); };
- まず、ObserveしたいElementを
useObserver
に渡すために、ref
を作成します。
つぎに、ObserveしたいElementにprops
としてref
を渡せば完了です。
今回の例では、最初はref
を渡したDiv Elementが画面に表示されていないため、画面上部中央には、not visibleと表示されます。
画面をスクロールして、ref
を渡したDiv Elementが画面に表示されると、visibleと表示されます。infinite scroll
今回実装したもの
ソースコード
import React, { useEffect, useState, RefObject } from 'react'; import axios from 'axios'; import './App.css'; interface Item { id: number; image: string; } const fetchItems = async (): Promise<Item[]> => { const { data } = await axios.get('http://localhost:3000/item'); return data; }; function useObserver( ref: RefObject<HTMLElement>, ops?: IntersectionObserverInit ) { const [intersect, setIntersect] = useState<boolean>(false); useEffect(() => { function callback(entries: IntersectionObserverEntry[]) { entries.forEach(entry => { setIntersect(entry.isIntersecting); }); } const observer = new IntersectionObserver(callback, ops); if (ref.current === null) { return; } observer.observe(ref.current); return () => { if (ref.current !== null) { observer.unobserve(ref.current); } }; }); return intersect; } export const App: React.FC = () => { const [items, setItems] = useState<Item[]>([]); const target = React.useRef<HTMLDivElement>(null); const intersect = useObserver(target); useEffect(() => { async function fetch() { const data = await fetchItems(); setItems(data); } fetch(); }, []); useEffect(() => { async function fetch() { if (intersect) { const data = await fetchItems(); setItems(prevItems => [...prevItems, ...data]); } } fetch(); }, [intersect]); return ( <React.Fragment> <div className="container"> {items && items.map((item) => { return ( <React.Fragment key={item.id}> <div className="card"> <div className="card__item"> <img src={item.image}></img> </div> </div> </React.Fragment> ); })} </div> <div ref={target}>loading</div> </React.Fragment> ); }; export default Appおわりに
今回はintersection observerを使用してInfinite Scrollの実装を行いました.
intersection observerはその他にも,オフスクリーン時の画像の遅延読み込みやコンポーネントの遅延読み込みなどの使用法が考えられます.
要素をobserveして何かするという処理を書くことは結構あると思うので,積極的に使用していきましょう
- 投稿日:2019-12-22T07:31:46+09:00
React Nativeの公式チュートリアルをReact Hooks + TypeScriptで書き換える
前書き
個人的な学習のためReact Nativeの公式チュートリアルをやります。ただ、チュートリアルはReact Class APIを利用しているため、今回はそれをReact Hooks + TypeScriptに置き換えながら学習していこうと思います。
本記事は、「該当ページ」 「ClassAPIでの書き方」 「Hooksでの書き方」をセットにして記述していきます。Learn the Basics
該当ページ: https://facebook.github.io/react-native/docs/tutorial
classimport React, { Component } from 'react'; import { Text, View } from 'react-native'; export default class HelloWorldApp extends Component { render() { return ( <View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}> <Text>Hello, world!</Text> </View> ); } }hooksimport React, {Component} from 'react'; import {Text, View} from 'react-native'; export default () => { return ( <View style={{flex: 1, justifyContent: 'center', alignItems: 'center'}}> <Text>Hello, world!</Text> </View> ); };Props
該当ページ: https://facebook.github.io/react-native/docs/props
classimport React, { Component } from 'react'; import { Text, View } from 'react-native'; class Greeting extends Component { render() { return ( <View style={{alignItems: 'center'}}> <Text>Hello {this.props.name}!</Text> </View> ); } } export default class LotsOfGreetings extends Component { render() { return ( <View style={{alignItems: 'center', top: 50}}> <Greeting name='Rexxar' /> <Greeting name='Jaina' /> <Greeting name='Valeera' /> </View> ); } }hooksimport React, {Component} from 'react'; import {Text, View} from 'react-native'; interface Props { name: string; } const Greeting = (props: Props) => { return ( <View style={{alignItems: 'center'}}> <Text>Hello {props.name}!</Text> </View> ); }; export default class LotsOfGreetings extends Component { render() { return ( <View style={{alignItems: 'center', top: 50}}> <Greeting name="Rexxar" /> <Greeting name="Jaina" /> <Greeting name="Valeera" /> </View> ); } }State
該当ページ: https://facebook.github.io/react-native/docs/state
classimport React, {Component} from 'react'; import {Text, View} from 'react-native'; class Blink extends Component { componentDidMount() { // Toggle the state every second setInterval( () => this.setState(previousState => ({ isShowingText: !previousState.isShowingText, })), 1000, ); } //state object state = {isShowingText: true}; render() { if (!this.state.isShowingText) { return null; } return <Text>{this.props.text}</Text>; } } export default class BlinkApp extends Component { render() { return ( <View> <Blink text="I love to blink" /> <Blink text="Yes blinking is so great" /> <Blink text="Why did they ever take this out of HTML" /> <Blink text="Look at me look at me look at me" /> </View> ); } }hooksimport React, {Component, useEffect, useState} from 'react'; import {Text, View} from 'react-native'; interface Props { text: string; } const Blink = (props: Props) => { useEffect(() => { // Toggle the state every second setInterval(() => setState(!isShowingText), 1000); }, []); //state object const [isShowingText, setState] = useState(true); if (!isShowingText) { return null; } return <Text>{props.text}</Text>; }; export default () => { return ( <View> <Blink text="I love to blink" /> <Blink text="Yes blinking is so great" /> <Blink text="Why did they ever take this out of HTML" /> <Blink text="Look at me look at me look at me" /> </View> ); };Style
該当ページ: https://facebook.github.io/react-native/docs/style
classimport React, { Component } from 'react'; import { StyleSheet, Text, View } from 'react-native'; const styles = StyleSheet.create({ bigBlue: { color: 'blue', fontWeight: 'bold', fontSize: 30, }, red: { color: 'red', }, }); export default class LotsOfStyles extends Component { render() { return ( <View> <Text style={styles.red}>just red</Text> <Text style={styles.bigBlue}>just bigBlue</Text> <Text style={[styles.bigBlue, styles.red]}>bigBlue, then red</Text> <Text style={[styles.red, styles.bigBlue]}>red, then bigBlue</Text> </View> ); } }hooksimport React from 'react'; import {StyleSheet, Text, View} from 'react-native'; const styles = StyleSheet.create({ bigBlue: { color: 'blue', fontWeight: 'bold', fontSize: 30, }, red: { color: 'red', }, }); export default () => { return ( <View> <Text style={styles.red}>just red</Text> <Text style={styles.bigBlue}>just bigBlue</Text> <Text style={[styles.bigBlue, styles.red]}>bigBlue, then red</Text> <Text style={[styles.red, styles.bigBlue]}>red, then bigBlue</Text> </View> ); };Height and Width
該当ページ: https://facebook.github.io/react-native/docs/height-and-width
classimport React, { Component } from 'react'; import { View } from 'react-native'; export default class FlexDimensionsBasics extends Component { render() { return ( // Try removing the `flex: 1` on the parent View. // The parent will not have dimensions, so the children can't expand. // What if you add `height: 300` instead of `flex: 1`? <View style={{flex: 1}}> <View style={{flex: 1, backgroundColor: 'powderblue'}} /> <View style={{flex: 2, backgroundColor: 'skyblue'}} /> <View style={{flex: 3, backgroundColor: 'steelblue'}} /> </View> ); } }hooksimport React, {Component} from 'react'; import {View} from 'react-native'; export default () => { return ( // Try removing the `flex: 1` on the parent View. // The parent will not have dimensions, so the children can't expand. // What if you add `height: 300` instead of `flex: 1`? <View style={{flex: 2}}> <View style={{flex: 1, backgroundColor: 'powderblue'}} /> <View style={{flex: 2, backgroundColor: 'skyblue'}} /> <View style={{flex: 3, backgroundColor: 'steelblue'}} /> </View> ); };Layout with Flexbox
該当ページ: https://facebook.github.io/react-native/docs/flexbox
classimport React, { Component } from 'react'; import { View } from 'react-native'; export default class FlexDirectionBasics extends Component { render() { return ( // Try setting `flexDirection` to `column`. <View style={{flex: 1, flexDirection: 'row'}}> <View style={{width: 50, height: 50, backgroundColor: 'powderblue'}} /> <View style={{width: 50, height: 50, backgroundColor: 'skyblue'}} /> <View style={{width: 50, height: 50, backgroundColor: 'steelblue'}} /> </View> ); } };hooksimport React, {Component} from 'react'; import {View} from 'react-native'; export default () => { return ( // Try setting `flexDirection` to `column`. <View style={{flex: 1, flexDirection: 'row'}}> <View style={{width: 50, height: 50, backgroundColor: 'powderblue'}} /> <View style={{width: 50, height: 50, backgroundColor: 'skyblue'}} /> <View style={{width: 50, height: 50, backgroundColor: 'steelblue'}} /> </View> ); };classimport React, {Component} from 'react'; import {View} from 'react-native'; export default class JustifyContentBasics extends Component { render() { return ( // Try setting `justifyContent` to `center`. // Try setting `flexDirection` to `row`. <View style={{ flex: 1, flexDirection: 'column', justifyContent: 'center', }}> <View style={{width: 50, height: 50, backgroundColor: 'powderblue'}} /> <View style={{width: 50, height: 50, backgroundColor: 'skyblue'}} /> <View style={{width: 50, height: 50, backgroundColor: 'steelblue'}} /> </View> ); } }hooksimport React, {Component} from 'react'; import {View} from 'react-native'; export default () => { return ( // Try setting `justifyContent` to `center`. // Try setting `flexDirection` to `row`. <View style={{ flex: 1, flexDirection: 'column', justifyContent: 'center', }}> <View style={{width: 50, height: 50, backgroundColor: 'powderblue'}} /> <View style={{width: 50, height: 50, backgroundColor: 'skyblue'}} /> <View style={{width: 50, height: 50, backgroundColor: 'steelblue'}} /> </View> ); };classimport React, { Component } from 'react'; import { View } from 'react-native'; export default class AlignItemsBasics extends Component { render() { return ( // Try setting `alignItems` to 'flex-start' // Try setting `justifyContent` to `flex-end`. // Try setting `flexDirection` to `row`. <View style={{ flex: 1, flexDirection: 'column', justifyContent: 'center', alignItems: 'stretch', }}> <View style={{width: 50, height: 50, backgroundColor: 'powderblue'}} /> <View style={{height: 50, backgroundColor: 'skyblue'}} /> <View style={{height: 100, backgroundColor: 'steelblue'}} /> </View> ); } };hooksimport React, {Component} from 'react'; import {View} from 'react-native'; export default () => { return ( // Try setting `alignItems` to 'flex-start' // Try setting `justifyContent` to `flex-end`. // Try setting `flexDirection` to `row`. <View style={{ flex: 1, flexDirection: 'column', justifyContent: 'center', alignItems: 'stretch', }}> <View style={{width: 50, height: 50, backgroundColor: 'powderblue'}} /> <View style={{height: 50, backgroundColor: 'skyblue'}} /> <View style={{height: 100, backgroundColor: 'steelblue'}} /> </View> ); };Handling Text Input
該当ページ: https://facebook.github.io/react-native/docs/handling-text-input
classimport React, { Component } from 'react'; import { Text, TextInput, View } from 'react-native'; export default class PizzaTranslator extends Component { constructor(props) { super(props); this.state = {text: ''}; } render() { return ( <View style={{padding: 10}}> <TextInput style={{height: 40}} placeholder="Type here to translate!" onChangeText={(text) => this.setState({text})} value={this.state.text} /> <Text style={{padding: 10, fontSize: 42}}> {this.state.text.split(' ').map((word) => word && '?').join(' ')} </Text> </View> ); } }hooksimport React, {Component, useState} from 'react'; import {Text, TextInput, View} from 'react-native'; export default () => { const [text, setText] = useState(''); return ( <View style={{padding: 10}}> <TextInput style={{height: 40}} placeholder="Type here to translate!" onChangeText={text => setText(text)} value={text} /> <Text style={{padding: 10, fontSize: 42}}> {text .split(' ') .map(word => word && '?') .join(' ')} </Text> </View> ); };Handling Touches
該当ページ: https://facebook.github.io/react-native/docs/handling-touches
classimport React, {Component} from 'react'; import {Button, StyleSheet, View} from 'react-native'; export default class ButtonBasics extends Component { _onPressButton() { alert('You tapped the button!'); } render() { return ( <View style={styles.container}> <View style={styles.buttonContainer}> <Button onPress={this._onPressButton} title="Press Me" /> </View> <View style={styles.buttonContainer}> <Button onPress={this._onPressButton} title="Press Me" color="#841584" /> </View> <View style={styles.alternativeLayoutButtonContainer}> <Button onPress={this._onPressButton} title="This looks great!" /> <Button onPress={this._onPressButton} title="OK!" color="#841584" /> </View> </View> ); } } const styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'center', }, buttonContainer: { margin: 20, }, alternativeLayoutButtonContainer: { margin: 20, flexDirection: 'row', justifyContent: 'space-between', }, });hooksimport React, {Component} from 'react'; import {Button, StyleSheet, View} from 'react-native'; export default () => { const _onPressButton = () => { alert('You tapped the button!'); }; return ( <View style={styles.container}> <View style={styles.buttonContainer}> <Button onPress={_onPressButton} title="Press Me" /> </View> <View style={styles.buttonContainer}> <Button onPress={_onPressButton} title="Press Me" color="#841584" /> </View> <View style={styles.alternativeLayoutButtonContainer}> <Button onPress={_onPressButton} title="This looks great!" /> <Button onPress={_onPressButton} title="OK!" color="#841584" /> </View> </View> ); }; const styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'center', }, buttonContainer: { margin: 20, }, alternativeLayoutButtonContainer: { margin: 20, flexDirection: 'row', justifyContent: 'space-between', }, });classimport React, {Component} from 'react'; import { Platform, StyleSheet, Text, TouchableHighlight, TouchableOpacity, TouchableNativeFeedback, TouchableWithoutFeedback, View, } from 'react-native'; export default class Touchables extends Component { _onPressButton() { alert('You tapped the button!'); } _onLongPressButton() { alert('You long-pressed the button!'); } render() { return ( <View style={styles.container}> <TouchableHighlight onPress={this._onPressButton} underlayColor="white"> <View style={styles.button}> <Text style={styles.buttonText}>TouchableHighlight</Text> </View> </TouchableHighlight> <TouchableOpacity onPress={this._onPressButton}> <View style={styles.button}> <Text style={styles.buttonText}>TouchableOpacity</Text> </View> </TouchableOpacity> <TouchableNativeFeedback onPress={this._onPressButton} background={ Platform.OS === 'android' ? TouchableNativeFeedback.SelectableBackground() : '' }> <View style={styles.button}> <Text style={styles.buttonText}> TouchableNativeFeedback{' '} {Platform.OS !== 'android' ? '(Android only)' : ''} </Text> </View> </TouchableNativeFeedback> <TouchableWithoutFeedback onPress={this._onPressButton}> <View style={styles.button}> <Text style={styles.buttonText}>TouchableWithoutFeedback</Text> </View> </TouchableWithoutFeedback> <TouchableHighlight onPress={this._onPressButton} onLongPress={this._onLongPressButton} underlayColor="white"> <View style={styles.button}> <Text style={styles.buttonText}>Touchable with Long Press</Text> </View> </TouchableHighlight> </View> ); } } const styles = StyleSheet.create({ container: { paddingTop: 60, alignItems: 'center', }, button: { marginBottom: 30, width: 260, alignItems: 'center', backgroundColor: '#2196F3', }, buttonText: { textAlign: 'center', padding: 20, color: 'white', }, });hooksimport React, {Component} from 'react'; import { Platform, StyleSheet, Text, TouchableHighlight, TouchableOpacity, TouchableNativeFeedback, TouchableWithoutFeedback, View, } from 'react-native'; export default () => { const _onPressButton = () => { alert('You tapped the button!'); }; const _onLongPressButton = () => { alert('You long-pressed the button!'); }; return ( <View style={styles.container}> <TouchableHighlight onPress={_onPressButton} underlayColor="white"> <View style={styles.button}> <Text style={styles.buttonText}>TouchableHighlight</Text> </View> </TouchableHighlight> <TouchableOpacity onPress={_onPressButton}> <View style={styles.button}> <Text style={styles.buttonText}>TouchableOpacity</Text> </View> </TouchableOpacity> <TouchableNativeFeedback onPress={_onPressButton} background={ Platform.OS === 'android' ? TouchableNativeFeedback.SelectableBackground() : '' }> <View style={styles.button}> <Text style={styles.buttonText}> TouchableNativeFeedback{' '} {Platform.OS !== 'android' ? '(Android only)' : ''} </Text> </View> </TouchableNativeFeedback> <TouchableWithoutFeedback onPress={_onPressButton}> <View style={styles.button}> <Text style={styles.buttonText}>TouchableWithoutFeedback</Text> </View> </TouchableWithoutFeedback> <TouchableHighlight onPress={_onPressButton} onLongPress={_onLongPressButton} underlayColor="white"> <View style={styles.button}> <Text style={styles.buttonText}>Touchable with Long Press</Text> </View> </TouchableHighlight> </View> ); }; const styles = StyleSheet.create({ container: { paddingTop: 60, alignItems: 'center', }, button: { marginBottom: 30, width: 260, alignItems: 'center', backgroundColor: '#2196F3', }, buttonText: { textAlign: 'center', padding: 20, color: 'white', }, });Using a ScrollView
該当ページ: https://facebook.github.io/react-native/docs/using-a-scrollview
省略
Using List Views
該当ページ: https://facebook.github.io/react-native/docs/using-a-listview
classimport React, { Component } from 'react'; import { FlatList, StyleSheet, Text, View } from 'react-native'; export default class FlatListBasics extends Component { render() { return ( <View style={styles.container}> <FlatList data={[ {key: 'Devin'}, {key: 'Dan'}, {key: 'Dominic'}, {key: 'Jackson'}, {key: 'James'}, {key: 'Joel'}, {key: 'John'}, {key: 'Jillian'}, {key: 'Jimmy'}, {key: 'Julie'}, ]} renderItem={({item}) => <Text style={styles.item}>{item.key}</Text>} /> </View> ); } } const styles = StyleSheet.create({ container: { flex: 1, paddingTop: 22 }, item: { padding: 10, fontSize: 18, height: 44, }, })hooksimport React, {Component} from 'react'; import {FlatList, StyleSheet, Text, View} from 'react-native'; export default () => { return ( <View style={styles.container}> <FlatList data={[ {key: 'Devin'}, {key: 'Dan'}, {key: 'Dominic'}, {key: 'Jackson'}, {key: 'James'}, {key: 'Joel'}, {key: 'John'}, {key: 'Jillian'}, {key: 'Jimmy'}, {key: 'Julie'}, ]} renderItem={({item}) => <Text style={styles.item}>{item.key}</Text>} /> </View> ); }; const styles = StyleSheet.create({ container: { flex: 1, paddingTop: 22, }, item: { padding: 10, fontSize: 18, height: 44, }, });Networking
該当ページ: https://facebook.github.io/react-native/docs/network
classimport React from 'react'; import { FlatList, ActivityIndicator, Text, View } from 'react-native'; export default class FetchExample extends React.Component { constructor(props){ super(props); this.state ={ isLoading: true} } componentDidMount(){ return fetch('https://facebook.github.io/react-native/movies.json') .then((response) => response.json()) .then((responseJson) => { this.setState({ isLoading: false, dataSource: responseJson.movies, }, function(){ }); }) .catch((error) =>{ console.error(error); }); } render(){ if(this.state.isLoading){ return( <View style={{flex: 1, padding: 20}}> <ActivityIndicator/> </View> ) } return( <View style={{flex: 1, paddingTop:20}}> <FlatList data={this.state.dataSource} renderItem={({item}) => <Text>{item.title}, {item.releaseYear}</Text>} keyExtractor={({id}, index) => id} /> </View> ); } }hooksimport React, {useState, useEffect} from 'react'; import {FlatList, ActivityIndicator, Text, View} from 'react-native'; export default () => { const [isLoading, setIsLoading] = useState(true); const [dataSource, setDataSource] = useState([]); useEffect(() => { const fetchMovies = async () => { try { const response = await fetch( 'https://facebook.github.io/react-native/movies.json', ); const responseJson = await response.json(); setIsLoading(false); setDataSource(responseJson.movies); } catch (error) { console.error(error); } }; fetchMovies(); }, []); if (isLoading) { return ( <View style={{flex: 1, padding: 200}}> <ActivityIndicator /> </View> ); } return ( <View style={{flex: 1, paddingTop: 20}}> <FlatList data={dataSource} renderItem={({item}) => ( <Text> {item.title}, {item.releaseYear} </Text> )} keyExtractor={({id}, index) => id} /> </View> ); };感想
Class APIに対してReact Hooksはコンパクトで無駄のない記述ができて素晴らしいですね。
React Nativeは今回初めて触ったのですが、Reactに慣れていればとても使いやすそうだと感じました。
SwiftやKotlinでのネイティブアプリ開発をやってきましたが、もう戻れないんじゃないかと思うくらい開発体験が良さそうです。
もちろん実務で使うとなるとつらみは出てくるのでしょうが、それはそれとしてとにかく使ってみたいと思えるチュートリアルでした。
- 投稿日:2019-12-22T00:59:23+09:00
React Native ことはじめ
業務でReactNativeに強くなる必要があったので環境構築からメモ
いちいちググったりしなくていいようにここ見れば解決するような記事のつもり
画面のキャプチャなどは載せない予定
expoを使うと依存関係などまとめてくれるから楽らしい
expoはじめて使った感想としては、
・ライブプレビューすげえ!!
・(AndroidStudioと比較して)QRコード読み込むだけで(expoアプリのインストールは必要かな?)実機プレビューできんのヤバすぎでしょ!!
・各所での説明が初心者に優しいなコード書き始めるまでにやったこと
まずはwindowsで環境構築した手順のメモ
expo
GetStartWithExpo
に従う
以降は従ってやったことNode.js
まずはNode.jsをインストール
インストーラーの指示の通りでOK
Node.jsインストール後
cmdで
npm install expo-cli --global
を実行プロジェクト作成したいディレクトリに移動し、
expo init my-new-project
my-new-projectはそのままプロジェクト名になるので任意の名前に変更可能プロジェクト作成後
プロジェクトのディレクトリに移動
cd my-new-project
し、
npm start
ここはやってみた感じ
expo start
の方がいいかもしれない
この
expo start
はライブプレビュー起動するコマンドなので、作業するたび実行する必要なコマンドかとここでエラー発生
エラーメッセージは、
Metro 'Bundler' process exited with code 1
stackoverflowを参照。
プロジェクト内の\node_modules\metro-config\src\defaults\blacklist.js
のvar sharedBlacklist = [ /node_modules[/\\]react[/\\]dist[/\\].*/, /website\/node_modules\/.*/, /heapCapture\/bundle\.js/, /.*\/__tests__\/.*/ ];を
var sharedBlacklist = [ /node_modules[\/\\]react[\/\\]dist[\/\\].*/, /website\/node_modules\/.*/, /heapCapture\/bundle\.js/, /.*\/__tests__\/.*/ ];に書き換えることで解決。
個人的にはエスケープシーケンスあたりが関係していそうかな、と思った。
これで初期環境構築はできた
ここからはApp.jsを中心にゴリゴリにコード書いていく
初期構築から2〜3時間触ってみた感想
- (どの言語、フレームワークでも当たり前のことかもしれないけど、)公式ドキュメント読み込まなきゃ
特に、ググって出てくる情報が少ないような気がする
2. レイアウトなど、スタイルの考え方はCSSの考え方が使えそうReact Nativeで使うスタイル記述は、CSSの記述をケバブケースからキャメルケースに直したものっぽい
3. いままで使ってきたPythonみたいなスクリプト言語とは作りが違うので、慣れるのに時間がかかりそう特にコンポーネントの考え方とかは頭リセットして考えないとこんがらがりそう
この後は画面遷移中心に、がっつりアプリの形を作っていくので、react-navigationを含めて勉強していきます
- 投稿日:2019-12-22T00:40:41+09:00
MobX と hooks でプレーンな書き味の React コンポーネントを書く
MobX と React Hooks をうまく組み合わせ、プレーンなものと書き味の変わらない React コンポーネントを書くアイデアを紹介します。
(オリジナルのアイデアは私ではなく誰かのブログに書かれていましたが、そのリンクを失念してしまいました。見つけ次第、リンクを貼りたいと思います。)
MobX store を使うコンポーネントは特殊な書き方を強いられる
MobX の特徴 - observable な store クラス
MobX は Single Page Application における状態管理ライブラリーの一つで、よく React と組み合わせて使われます。
MobX が特徴的なのは、状態を observable な store クラスで表現する点です。具体的には次のように、
@observable
デコレーター をつけたメンバーを持つクラスです:TodosStore.tsimport { observable, computed, action, flow } from 'mobx' type Todo = { userId: number id: number title: string completed: boolean } /** * TODO 一覧を管理する store */ export default class TodosStore { /** * TODO 一覧 */ @observable todos: Todo[] /** * TODO 一覧を読み込み中のとき true */ @computed get loading() { return !this.todos } /** * TODO の一つの完了/未完了を切り替える * * @param id 対象の TODO の ID */ @action.bound toggle(id: number) { this.todos = this.todos.map(todo => { if (todo.id === id) { todo.completed = !todo.completed } return todo }) } /** * TODO 一覧を API から取得する */ fetch = flow(function*(this: TodosStore) { this.todos = yield fetch( 'https://jsonplaceholder.typicode.com/todos?userId=1', ).then(r => r.json()) }).bind(this) }React において、状態を表すオブジェクトは immutable であることが原則です。しかし MobX は、
@observable
デコレーターにより、mutable な store インスタンスを状態を表すオブジェクトとして扱えるようにしています。上記の
TodosStore
において observable なのはtodos
とloading
の値です。コードのイメージは次のようになります:const store = new TodosStore() store.todos // undefined store.loading // true // 非同期で TODO 一覧を取得する await store.fetch() // store インスタンスは同一のまま、メンバーの値だけが変化する。つまり store が mutable store.todos // [{...}, {...}, ...] store.loading // false強いられる特殊な書き方 - observer コンポーネント
React が immutable な状態オブジェクトを要求するのは、状態オブジェクトに変更があればそれを検知し直ちにコンポーネントを再描画するためです。Mutable なオブジェクトを状態に使うとその機構がうまく働かず、状態が変化したのに再描画が起きない不具合となります。その点は MobX を使ったとしても同じで、次のように書くと、TODO を読み込んだあとも再描画が起きなくなります:
App.tsximport React, { useEffect } from 'react' import { observer } from 'mobx-react' import { Section, Title, Loading, Todo } from './components' import TodosStore from './TodosStore' const store = new TodosStore() export default function App() { // store の todos や loading の値が変化したことをコンポーネントは検知できない。 const { todos, loading, toggle, fetch: fetchTodos } = store // そのため API から TODO 一覧を取得しても、 useEffect(() => { fetchTodos() }, [fetchTodos]) // ずっと読み込み中表示のまま。 if (loading) { return ( <Section> <Loading /> </Section> ) } // ここには至らない。 return ( <Section> <Title>Todos</Title> {todos.map(({ id, title, completed }) => ( <Todo key={id} label={title} completed={completed} onChange={() => toggle(id)} /> ))} </Section> ) }これを回避するため、MobX store を使うコンポーネントは特殊な書き方を強いられます。つまり、
observer
関数 でラップしてやる必要があります:App.tsximport React, { useEffect } from 'react' +import { observer } from 'mobx-react' import { Section, Title, Loading, Todo } from './components' import TodosStore from './TodosStore' const store = new TodosStore() -export default function App() { +export default observer(function App() { const { todos, loading, toggle, fetch: fetchTodos } = store useEffect(() => { ... ))} </Section> ) -} +})コンポーネントが MobX 専用となってしまい、再利用性やテストの観点でやりづらさを生じ得ます。できることなら JSX.Element を返すだけのただの関数 としてプレーンなコンポーネントを定義したいものです。
useObserver
hooks を使いobserver
をなくすmobx-react 公式で紹介されている方法 - JSX.Element を返す
observer
を使わない書き方は mobx-react のドキュメントで紹介されています。それはuseObserver
hook を使った書き方で、次のようなものです:App.tsximport React, { useEffect } from 'react' -import { observer } from 'mobx-react' +import { useObserver } from 'mobx-react' import { Section, Title, Loading, Todo } from './components' import TodosStore from './TodosStore' const store = new TodosStore() -export default observer(function App() { - const { todos, loading, toggle, fetch: fetchTodos } = store +export default function App() { + const { fetch: fetchTodos } = store useEffect(() => { fetchTodos() }, [fetchTodos]) + return useObserver(() => { + const { todos, loading, toggle } = store if (loading) { return ( <Section> <Loading /> </Section> ) } return ( <Section> ... </Section> ) -}) + }) +}たしかに
observer
によるラップは消えました。しかしuseObserver
の戻り値が JSX.Element で、違和感があります。何より、プレーンな関数であることが利点 のはずの hook が JSX.Element を返してしまうと、useObserver
やほかの hooks をまとめてカスタムフックを作ることが難しくなります。改良した方法 - store の slice を返す
ドキュメントでは JSX.Element を返す方法が紹介されていたものの、
useObserver
から JSX.Element を返す必要性はないようです。つまり store インスタンスをuseObserver
でラップしてやれば、コンポーネントは store の mutable な変化を検知できるようです:App.tsxconst store = new TodosStore() export default function App() { - const { fetch: fetchTodos } = store + // 複数の値を返すのにタプルを使っているが、単なる好み。オブジェクトを返すスタイルでもよい。 + const [todos, loading, toggle, fetchTodos] = useObserver(() => [ + store.todos, + store.loading, + store.toggle, + store.fetch, + ]) useEffect(() => { fetchTodos() }, [fetchTodos]) - return useObserver(() => { - const { todos, loading, toggle } = store - if (loading) { return ( <Section> ... ))} </Section> ) - }) }
useObserver
から、JSX.Element を返す代わりに、store の一部分 (slice) を返しています。JSX.Element をuseObserver
で囲むことも、コンポーネント全体をobserver
で囲むこともなくなり、プレーンなコンポーネントの書き方となりました。この書き方をベースに
useContext
を加え、より意味のあるカスタムフックを作ってみます。コンテキストを使うことにより App コンポーネントへ任意の store インスタンスを渡すことが可能になるほか、コンポーネントからは store を使っていることがほとんど意識されない書き味になります:App.tsximport React, { useEffect } from 'react' -import { useObserver } from 'mobx-react' import { Section, Title, Loading, Todo } from './components' -import TodosStore from './TodosStore' +import useTodosStore from './useTodosStore' - -const store = new TodosStore() export default function App() { - const [todos, loading, toggle, fetchTodos] = useObserver(() => [ + // このファイルにべた書きされた store インスタンスではなく、コンテキスト経由の store を使うことで、 + // テスト時や Storybook を使うときにモックしやすくなる。 + const [todos, loading, toggle, fetchTodos] = useTodosStore(store => [ store.todos, store.loading, store.toggle,ここで使われているカスタムフック
useTodosStore
の実装は次のようになります:useTodosStore.tsimport { createContext } from 'react' import useStore, { Selector } from './useStore' import TodosStore from './TodosStore' const context = createContext<TodosStore | null>(null) export const TodosProvider = context.Provider // TodosStore の slice を取得するための hook // 汎用の useStore を TodosStore 専用にした。 export default function useTodosStore<TSelection>(selector: Selector<TodosStore, TSelection>) { return useStore(context, selector) }useStore.tsimport { useContext } from 'react' import { useObserver } from 'mobx-react' export type Selector<TStore, TSelection> = (store: TStore) => TSelection // useContext と useObserver を組み合わせた、任意の store 型に対応したカスタムフック。 // この hook を介して store slice を取得すれば、コンポーネントが store の mutable な変更を検知できる。 export default function useStore<TStore, TSelection>( context: React.Context<TStore>, selector: Selector<TStore, TSelection>, ) { const store = useContext(context) if (!store) { throw new Error('need to pass a value to the context') } return useObserver(() => selector(store)) }カスタムフックによって store の実体が隠蔽され、store がどこでインスタンス化されたかだけでなくそれが MobX store かどうかすら気にすることなく、store を使えるようになりました。
Store の種類を増やすたびに
useXxxStore
のような hooks 名や定義ファイルが増えるのが気になる場合は、store の定義ファイルに hooks を書くなり、static メソッドとして hooks を(ついでに provider も)持たせるなりすればよいと思います。まとめ
- MobX store を使うコンポーネントは特殊な書き方を強いられる
observer
によるラップ- MobX 専用の書き方はなくしたい
useObserver
hook を使いobserver
をなくせる
useObserver
から store slice を返す- コンテキスト経由で store を受け渡すカスタムフックを作り、store の実体を隠蔽する
- カスタムフックの実装を変えれば、コンポーネントを修正することなく、MobX 以外の状態管理ライブラリーに切り替えることも可能