- 投稿日:2019-06-07T18:26:19+09:00
React+Amplify+AppSync+TypeScriptでリアルタイム掲示板アプリを作る
この記事は、「【爆速】React+Amplify+AppSyncでリアルタイム掲示板アプリを15分で作り上げる 〜これが最高のDeveloper Experienceだ〜 - Qiita」を参考にさせて頂きました。
Amplifyのコマンドでコードを自動生する際にTypeScriptを選択できるようなので、どんな感じなのか試してみました。
ついでに、ReactのHooksも使ってます。バージョン
使用した環境は以下のとおりです。
$ create-react-app --version 3.0.1 $ node -v v8.15.1 $ npm -v 6.9.0 $ amplify --version 1.7.0自分の環境にはamplifyのコマンドすら入っていない状態だったので、公式のページを見てインストールしました。
Getting Started · Create React App
Reactアプリの雛形を作る
create-react-app
で引数に--typescript
を指定して作成し、amplify init
で初期化設定をしていきます。
profile
の指定等は適宜変更して下さい。$ create-react-app boardapp --typescript $ cd boardapp $ amplify init Note: It is recommended to run this command from the root of your app directory ? Enter a name for the project boardapp ? Enter a name for the environment dev ? Choose your default editor: Visual Studio Code ? Choose the type of app that you're building javascript Please tell us about your project ? What javascript framework are you using react ? Source Directory Path: src ? Distribution Directory Path: build ? Build Command: npm run-script build ? Start Command: npm run-script start ? Do you want to use an AWS profile? Yes ? Please choose the profile you want to use defaultGraphQLのAPIを追加します。
$ amplify add api ? Please select from one of the below mentioned services GraphQL ? Provide API name: boardapp ? Choose an authorization type for the API API key ? Do you have an annotated GraphQL schema? No ? Do you want a guided schema creation? No ? Provide a custom type name Post以下のスキーマのサンプルが出来るので、そのまま使います。
amplify/backend/api/schema.graphqltype Post @model { id: ID! title: String! content: String! price: Int rating: Float }次に、デプロイとクライアントのコードの自動生成をします。
$ amplify push ? Are you sure you want to continue? Yes ? Do you want to generate code for your newly created GraphQL API Yes ? Choose the code generation language target typescript ? Enter the file name pattern of graphql queries, mutations and subscriptions src/graphql/**/*.ts ? Do you want to generate/update all possible GraphQL operations - queries, mutations and subscriptions Yes ? Enter maximum statement depth [increase from default if your schema is deeply nested] 2 ? Enter the file name for the generated code src/API.tsここまで終わると、GraplQLのAPIがAWSにデプロイされ、ローカルのディレクトリは以下のような構成になりました。
$ tree -L 5 -I "node_modules" . ├── README.md ├── amplify │ ├── #current-cloud-backend │ │ ├── amplify-meta.json │ │ ├── api │ │ │ └── boardapp │ │ │ ├── build │ │ │ ├── parameters.json │ │ │ ├── resolvers │ │ │ ├── schema.graphql │ │ │ └── stacks │ │ └── backend-config.json │ ├── backend │ │ ├── amplify-meta.json │ │ ├── api │ │ │ └── boardapp │ │ │ ├── build │ │ │ ├── parameters.json │ │ │ ├── resolvers │ │ │ ├── schema.graphql │ │ │ └── stacks │ │ ├── awscloudformation │ │ │ └── nested-cloudformation-stack.yml │ │ └── backend-config.json │ └── team-provider-info.json ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ └── manifest.json ├── src │ ├── API.ts │ ├── App.css │ ├── App.test.tsx │ ├── App.tsx │ ├── aws-exports.js │ ├── graphql │ │ ├── mutations.ts │ │ ├── queries.ts │ │ ├── schema.json │ │ └── subscriptions.ts │ ├── index.css │ ├── index.tsx │ ├── logo.svg │ ├── react-app-env.d.ts │ └── serviceWorker.ts ├── tsconfig.json └── yarn.lockamplifyのパッケージ追加
yarnでパッケージを登録します。
TypeScriptの型も一緒にに登録されるようです。$ yarn add aws-amplify aws-amplify-react
アプリケーションの更新
create-react-app
で自動生成されたコードを変更していきます。
まず、Amplifyの初期化部分です。src/index.tsximport React from 'react'; import ReactDOM from 'react-dom'; import './index.css'; import App from './App'; import * as serviceWorker from './serviceWorker'; import Amplify from "aws-amplify" // 追加 import config from "./aws-exports" // 追加 Amplify.configure(config) // 追加 ReactDOM.render(<App />, document.getElementById('root')); // If you want your app to work offline and load faster, you can change // unregister() to register() below. Note this comes with some pitfalls. // Learn more about service workers: https://bit.ly/CRA-PWA serviceWorker.unregister();次に、アプリ本体です。ポイントは後ほど解説します。
また、流れを掴む事が目的のため、エラー処理は入れてません。src/App.tsximport React, { useEffect, useState } from "react"; import { API, graphqlOperation } from "aws-amplify"; import { listPosts } from "./graphql/queries"; import { createPost } from "./graphql/mutations"; import { onCreatePost } from "./graphql/subscriptions"; import { ListPostsQuery, OnCreatePostSubscription, CreatePostMutationVariables } from "./API"; type Post = { id: string; title: string; content: string; price: number | null; rating: number | null; }; type FormState = { title: string; content: string; }; type PostSubscriptionEvent = { value: { data: OnCreatePostSubscription } }; const usePosts = () => { const [posts, setPosts] = useState<Post[]>([]); useEffect(() => { (async () => { // 最初のPost一覧取得 const result = await API.graphql(graphqlOperation(listPosts)); if ("data" in result && result.data) { const posts = result.data as ListPostsQuery; if (posts.listPosts) { setPosts(posts.listPosts.items as Post[]); } } // Post追加イベントの購読 const client = API.graphql(graphqlOperation(onCreatePost)); if ("subscribe" in client) { client.subscribe({ next: ({ value: { data } }: PostSubscriptionEvent) => { if (data.onCreatePost) { const post: Post = data.onCreatePost; setPosts(prev => [...prev, post]); } } }); } })(); }, []); return posts; }; const App: React.FC = () => { const [input, setInput] = useState<FormState>({ title: "", content: "" }); const posts = usePosts(); const onFormChange = ({ target: { name, value } }: React.ChangeEvent<HTMLInputElement>) => { setInput(prev => ({ ...prev, [name]: value })); }; const onPost = () => { if (input.title === "" || input.content === "") return; const newPost: CreatePostMutationVariables = { input: { title: input.title, content: input.content } }; setInput({ title: "", content: "" }); API.graphql(graphqlOperation(createPost, newPost)); }; return ( <div className="App"> <div> タイトル <input value={input.title} name="title" onChange={onFormChange} /> </div> <div> 内容 <input value={input.content} name="content" onChange={onFormChange} /> </div> <button onClick={onPost}>追加</button> <div> {posts.map(data => { return ( <div key={data.id}> <h4>{data.title}</h4> <p>{data.content}</p> </div> ); })} </div> </div> ); }; export default App;あとは、起動するだけです。
$ yarn start
複数画面開くと、同時にリアルタイムで更新されます。
解説と感想
モデル
graphqlのスキーマに対応した型が
src/API.ts
に自動生成されているので、基本的にここに定義されている型を使います。src/API.tsexport type ListPostsQuery = { listPosts: { __typename: "ModelPostConnection", items: Array< { __typename: "Post", id: string, title: string, content: string, price: number | null, rating: number | null, } | null > | null, nextToken: string | null, } | null, }; export type OnUpdatePostSubscription = { onUpdatePost: { __typename: "Post", id: string, title: string, content: string, price: number | null, rating: number | null, } | null, };Postの中身のみの型が無かったので、以下のように独自に定義しています。
src/App.tsxtype Post = { id: string; title: string; content: string; price: number | null; rating: number | null; };GraphQLのスキーマそのままなので、自動生成されて欲しい気もします。
登録
追加ボタンを押したときに呼ばれるメソッドです。
src/App.tsxconst onPost = () => { if (input.title === "" || input.content === "") return; const newPost: CreatePostMutationVariables = { input: { title: input.title, content: input.content } }; setInput({ title: "", content: "" }); API.graphql(graphqlOperation(createPost, newPost)); };GraphQLの代表的なクエリが自動生成されているので、
graphqlOperation
に指定することで、クエリの種類を切り替えられます。ここでは新規登録なので、createPost
を使います。src/graphql/mutations.tsexport const createPost = `mutation CreatePost($input: CreatePostInput!) { createPost(input: $input) { id title content price rating } } `;追加するときの引数
$input: CreatePostInput!
に対応する型も自動生成されているので、これに登録したいデータを設定してクエリを送信するだけです。src/API.tsexport type CreatePostInput = { id?: string | null, title: string, content: string, price?: number | null, rating?: number | null, }; export type CreatePostMutationVariables = { input: CreatePostInput, };一覧取得とデータ登録の監視
登録されたデータの一覧取得と追加されたデータの監視は、カスタムフックを作って実現しています。
useEffect
でコンポーネントのマウント時に、Postの一覧取得、Post作成の購読を追加を順番に行い、useState
で作成したPost一覧を戻り値として返す事で、Post一覧の更新を伝えます。src/App.tsxtype PostSubscriptionEvent = { value: { data: OnCreatePostSubscription } }; const usePosts = () => { const [posts, setPosts] = useState<Post[]>([]); useEffect(() => { (async () => { // 最初のPost一覧取得 const result = await API.graphql(graphqlOperation(listPosts)); if ("data" in result && result.data) { const posts = result.data as ListPostsQuery; if (posts.listPosts) { setPosts(posts.listPosts.items as Post[]); } } // Post追加イベントの購読 const client = API.graphql(graphqlOperation(onCreatePost)); if ("subscribe" in client) { client.subscribe({ next: ({ value: { data } }: PostSubscriptionEvent) => { if (data.onCreatePost) { const post: Post = data.onCreatePost; setPosts(prev => [...prev, post]); } } }); } })(); }, []); return posts; };型の整合性を取るため、少しややこしいです。
API.graphql
の戻り値の型はPromise<GraphQLResult> | Observable<object>
となっています。
引数のgraphqlOperation
の内容によって戻り値の型が変わります。また、戻り値のデータ型がobject
で、ジェネリックで型の指定も出来ないので、所々if
で型を絞り込んだり、as
でキャストしてます。もっとうまい使い方があるのかもしれませんが、もう少し使いやすくならないかなーと思いました。
さいごに
最初は全体のイメージが掴みづらかったのですが、実際に使ってみると
思ったよりも簡単にGraphQLのAPIが作れました。
DynamoDB以外にもRDBやLambdaとも連携できるようなので、色々と応用も出来て便利そうです。
- 投稿日:2019-06-07T17:19:01+09:00
React Hooksでデータを取得する方法
Robin Wieruch氏によるHow to fetch data with React Hooks?を著者の許可を得て意訳しました。
誤りやより良い表現などがあればご指摘頂けると助かります。原文: https://www.robinwieruch.de/react-hooks-fetch-data/
このチュートリアルでは、ステートフックと副作用フックでデータを取得する方法を解説します。テック系の人気記事を取得するためによく使われるHacker News APIを利用します。また、アプリケーション内の任意の場所で再利用したり、スタンドアロンのnodeパッケージとしてnpmに公開したりできるデータ取得用のカスタムフックも実装します。
React のこの新機能が初見であれば、まずReact Hooks入門に目を通してください。完成したプロジェクトでReact Hoos でのデータ取得事例を確認したければ、このGitHub リポジトリをどうぞ。
データ取得用の手軽な React フックが欲しいだけであれば、
npm install use-data-api
してドキュメントに従ってください。導入するのであればスターを付けるのも忘れずに^^注: 将来的には、React Hooks はデータ取得を目的としたものにはなりません。代わりに Suspense という機能がそれを担います。それでも、以下のチュートリアルは state と副作用フックについて習熟するための素晴らしい方法です。
React Hooks によるデータ取得
React でのデータ取得に不慣れであれば、Reactでのデータ取得大全に目を通すことをオススメします。React クラスコンポーネントでのデータ取得、レンダープロップコンポーネントとHOCsによってコンポーネントを再利用する方法、そしてエラーハンドリングとローディングスピナーの処理法について学ぶことができます。この記事では、それら全てを関数コンポーネント内の React Hooks で実装します。
import React, { useState } from 'react'; function App() { const [data, setData] = useState({ hits: [] }); return ( <ul> {data.hits.map(item => ( <li key={item.objectID}> <a href={item.url}>{item.title}</a> </li> ))} </ul> ); } export default App;App コンポーネントに項目の一覧(Hacker News での検索にヒットした記事)が表示されます。state およびその更新関数は、
useState
というステートフックから取得されます。これは App コンポーネント用に取得するデータのローカル state を管理します。初期 state はデータを示すオブジェクト内の hits に空配列が含まれます。このデータにはまだ誰も state を設定していません。このチュートリアルではaxiosを使ってデータ取得を行いますが、他のデータ取得ライブラリや、ブラウザネイティブの fetch API を使っても構いません。axios をまだインストールしていないのであれば、コマンドラインで
npm install axios
してください。準備ができたらデータ取得用の副作用フックを実装していきましょう。+ import React, { useState, useEffect } from 'react'; + import axios from 'axios'; function App() { const [data, setData] = useState({ hits: [] }); + useEffect(async () => { + const result = await axios( + 'http://hn.algolia.com/api/v1/search?query=redux', + ); + + setData(result.data); + }); return ( <ul> {data.hits.map(item => ( <li key={item.objectID}> <a href={item.url}>{item.title}</a> </li> ))} </ul> ); } export default App;useEffect という副作用フックは、API から axios でデータを取得し、ステートフックの更新関数によってコンポーネントのローカル state にデータを設定します。promise は async/await によって解決されます。
しかし、アプリケーションを実行すると厄介なループに陥るでしょう。副作用フックはコンポーネントのマウント時だけでなく、更新時にも実行されます。データを取得するたびに state を設定しているため、コンポーネントが更新されて副作用が再び実行されるからです。データ取得を何度も繰り返してしまいます。これはバグなので回避する必要があります。コンポーネントのマウント時にだけデータを取得するようにしましょう。 副作用フックの第2引数に空配列を渡すことで、コンポーネント更新時ではなくマウント時にだけ有効化することができます。
import React, { useState, useEffect } from 'react'; import axios from 'axios'; function App() { const [data, setData] = useState({ hits: [] }); useEffect(async () => { const result = await axios( 'http://hn.algolia.com/api/v1/search?query=redux', ); setData(result.data); + }, []); return ( <ul> {data.hits.map(item => ( <li key={item.objectID}> <a href={item.url}>{item.title}</a> </li> ))} </ul> ); } export default App;
第2引数はフックが依存する全ての変数(この配列に割り当てられている)を定義するために使われます。その変数が更新されるとフックは再度実行されます。変数の配列が空であれば、フックはコンポーネントの更新時に実行されません。変数を監視する必要がないためです。
最後にもう一点。コード内でサードパーティの API からデータを取得するために async/await を使用しています。ドキュメントによると、asyncアノテーションが付けられた関数はいずれも暗黙の promise を返します。「async 関数宣言は非同期関数を定義します。これは非同期関数オブジェクトを返します。非同期関数は、結果を返すために暗黙的な Promise を使ってイベントループ経由で非同期で動作する関数です。」しかし、副作用フックは何も返さないか、クリーンアップ関数を返すべきです。そのため、開発者コンソールには次の警告が表示されるかもしれません。07:41:22.910 index.js:1452 Warning: useEffect function must return a cleanup function or nothing. Promises and useEffect(async () => …) are not supported, but you can call an async function inside an effect.
useEffect
関数内で直接 async を使用することはできませんので、副作用内で async 関数を使うことで回避しましょう。import React, { useState, useEffect } from 'react'; import axios from 'axios'; function App() { const [data, setData] = useState({ hits: [] }); useEffect(() => { + const fetchData = async () => { + const result = await axios( + 'http://hn.algolia.com/api/v1/search?query=redux', + ); + setData(result.data); + }; fetchData(); }, []); return ( <ul> {data.hits.map(item => ( <li key={item.objectID}> <a href={item.url}>{item.title}</a> </li> ))} </ul> ); } export default App;これが React Hooks を使った簡単なデータ取得です。しかし、エラーハンドリング、ローディングインジケータ、フォームからデータ取得を実行する方法や再利用可能なデータ取得フックの実装方法などに興味があればそのまま読み進めてください。
プログラムもしくは手動でフックをトリガーするには?
上手いことコンポーネントのマウント時に一度だけデータを取得できました。しかしどの話題に関心があるのかをAPIに伝えるためにインプットフィールドを使うにはどうすれば良いのでしょうか?「Redux」がデフォルトの query として設定されていますが、「React」の話題に関心があるとしたら?「Redux」以外の話題を取得できるようにインプット要素を実装してみましょう。まずインプット要素に新しい state を導入します。
import React, { Fragment, useState, useEffect } from 'react'; import axios from 'axios'; function App() { const [data, setData] = useState({ hits: [] }); + const [query, setQuery] = useState('redux'); useEffect(() => { const fetchData = async () => { const result = await axios( 'http://hn.algolia.com/api/v1/search?query=redux', ); setData(result.data); }; fetchData(); }, []); return ( <Fragment> + <input + type="text" + value={query} + onChange={event => setQuery(event.target.value)} + /> <ul> {data.hits.map(item => ( <li key={item.objectID}> <a href={item.url}>{item.title}</a> </li> ))} </ul> </Fragment> ); } export default App;現時点では、どちらの state もお互いに独立していますが、それらを結合して、インプットフィールドの query で指定した記事だけを取得するようにしてみましょう。次のような変更により、コンポーネントはマウント時に query でヒットした記事だけを取得するようになります。
... function App() { const [data, setData] = useState({ hits: [] }); const [query, setQuery] = useState('redux'); useEffect(() => { const fetchData = async () => { const result = await axios( + `http://hn.algolia.com/api/v1/search?query=${query}`, ); setData(result.data); }; fetchData(); }, []); return ( ... ); } export default App;
1つ考慮もれがあります。マウント後にインプットフィールドに何かを入力しても、副作用でデータを取得することができていません。副作用の第2引数に空配列を渡しているためです。副作用は変数に依存していないため、コンポーネントのマウント時にだけ実行されます。しかし、副作用は query に依存するべきです。query の更新によってデータリクエストが再度実行されます。
... function App() { const [data, setData] = useState({ hits: [] }); const [query, setQuery] = useState('redux'); useEffect(() => { const fetchData = async () => { const result = await axios( `http://hn.algolia.com/api/v1/search?query=${query}`, ); setData(result.data); }; fetchData(); + }, [query]); return ( ... ); } export default App;
インプットフィールドの更新に応じてデータの再取得が実行されるはずです。しかしここでまた別の問題があります。一文字入力するたびに副作用によってデータ取得リクエストが実行されてしまいます。ボタンを用意して手動でフックを実行してリクエストするようにしましょう。
function App() { const [data, setData] = useState({ hits: [] }); const [query, setQuery] = useState('redux'); + const [search, setSearch] = useState(''); useEffect(() => { const fetchData = async () => { const result = await axios( `http://hn.algolia.com/api/v1/search?query=${query}`, ); setData(result.data); }; fetchData(); }, [query]); return ( <Fragment> <input type="text" value={query} onChange={event => setQuery(event.target.value)} /> + <button type="button" onClick={() => setSearch(query)}> + Search + </button> <ul> {data.hits.map(item => ( <li key={item.objectID}> <a href={item.url}>{item.title}</a> </li> ))} </ul> </Fragment> ); }ここで、副作用はインプットフィールドで一文字入力するたびに更新される query state ではなく search state に依存するようにします。ユーザーがボタンをクリックすると、新しい search state が設定されて、手動で副作用フックが実行されるはずです。
... function App() { const [data, setData] = useState({ hits: [] }); const [query, setQuery] = useState('redux'); + const [search, setSearch] = useState('redux'); useEffect(() => { const fetchData = async () => { const result = await axios( + `http://hn.algolia.com/api/v1/search?query=${search}`, ); setData(result.data); }; fetchData(); + }, [search]); return ( ... ); } export default App;また、search state の初期値は query state と同じ値になります。コンポーネントがマウント時にもデータ取得を実行するため、結果はインプットフィールドに反映されます。しかし、よく似た query と search state は混乱の元です。search state の代わりに実際のURL を設定してみましょう。
function App() { const [data, setData] = useState({ hits: [] }); const [query, setQuery] = useState('redux'); + const [url, setUrl] = useState( + 'http://hn.algolia.com/api/v1/search?query=redux', + ); useEffect(() => { const fetchData = async () => { + const result = await axios(url); setData(result.data); }; fetchData(); + }, [url]); return ( <Fragment> <input type="text" value={query} onChange={event => setQuery(event.target.value)} /> <button type="button" onClick={() => + setUrl(`http://hn.algolia.com/api/v1/search?query=${query}`) } > Search </button> <ul> {data.hits.map(item => ( <li key={item.objectID}> <a href={item.url}>{item.title}</a> </li> ))} </ul> </Fragment> ); }これが暗黙的なプログラムでのデータ取得を副作用フックで実装した例になります。副作用がどの state に依存するかを決めることができます。クリックもしくは別の副作用でこの state を設定すると、この副作用が再度実行されます。この例では、URL state が更新されると副作用が再度実行されて API から話題を取得します。
React Hooks によるローディングインジケータ
データ取得にローディングインジケータを導入しましょう。これは単純にステートフックで管理される別の state です。ローディングフラグは、App コンポーネントでローディングインジケータをレンダーするために使われます。
import React, { Fragment, useState, useEffect } from 'react'; import axios from 'axios'; function App() { const [data, setData] = useState({ hits: [] }); const [query, setQuery] = useState('redux'); const [url, setUrl] = useState( 'http://hn.algolia.com/api/v1/search?query=redux', ); + const [isLoading, setIsLoading] = useState(false); useEffect(() => { const fetchData = async () => { + setIsLoading(true); const result = await axios(url); setData(result.data); + setIsLoading(false); }; fetchData(); }, [url]); return ( <Fragment> <input type="text" value={query} onChange={event => setQuery(event.target.value)} /> <button type="button" onClick={() => setUrl(`http://hn.algolia.com/api/v1/search?query=${query}`) } > Search </button> + {isLoading ? ( + <div>Loading ...</div> + ) : ( <ul> {data.hits.map(item => ( <li key={item.objectID}> <a href={item.url}>{item.title}</a> </li> ))} </ul> + )} </Fragment> ); } export default App;コンポーネントのマウント時もしくは URL state が更新されたタイミングでデータ取得用の副作用が実行されると、loading state は true になります。リクエストが解決されると、loading state は再び false になります。
React Hooks によるエラーハンドリング
React Hooks でのデータ取得に対するエラーハンドリングについても学んでいきましょう。エラーはステートフックで初期化されるまた別の state に過ぎません。isError state が true の時、App コンポーネントはユーザーにフィードバックを提供することができます。async/await を導入しているのであれば、エラーハンドリングのために try/catch ブロックを使うのが一般的です。副作用内に記述していきましょう。
import React, { Fragment, useState, useEffect } from 'react'; import axios from 'axios'; function App() { const [data, setData] = useState({ hits: [] }); const [query, setQuery] = useState('redux'); const [url, setUrl] = useState( 'http://hn.algolia.com/api/v1/search?query=redux', ); const [isLoading, setIsLoading] = useState(false); + const [isError, setIsError] = useState(false); useEffect(() => { const fetchData = async () => { + setIsError(false); setIsLoading(true); + try { const result = await axios(url); setData(result.data); + } catch (error) { + setIsError(true); + } setIsLoading(false); }; fetchData(); }, [url]); return ( <Fragment> <input type="text" value={query} onChange={event => setQuery(event.target.value)} /> <button type="button" onClick={() => setUrl(`http://hn.algolia.com/api/v1/search?query=${query}`) } > Search </button> + {isError && <div>Something went wrong ...</div>} {isLoading ? ( <div>Loading ...</div> ) : ( <ul> {data.hits.map(item => ( <li key={item.objectID}> <a href={item.url}>{item.title}</a> </li> ))} </ul> )} </Fragment> ); } export default App;isError state はフックが再実行されるたびにリセットされます。失敗したリクエストの後にユーザーがもう一度試行すると isError がリセットされるので便利です。自らエラーを強制するために URL を無効なものに変更することができますので、エラーメッセージが表示されることを確認してください。
フォームと React によるデータ取得
フォームを使ったデータ取得についてもやっていきましょう。今までのところ、インプットフィールドとボタンの組み合わせしかありません。インプット要素が増えてくると、フォーム要素でラップしたくなるかもしれません。また、フォームはキーボードの「エンター」でボタンを実行することも可能です。
function App() { ... return ( <Fragment> + <form + onSubmit={() => + setUrl(`http://hn.algolia.com/api/v1/search?query=${query}`) + } + > <input type="text" value={query} onChange={event => setQuery(event.target.value)} /> + <button type="submit">Search</button> + </form> {isError && <div>Something went wrong ...</div>} ... </Fragment> ); }しかし現状では、送信ボタンを押すとブラウザがリロードしてしまいます。これはフォームを送信するときの本来の動作です。標準動作を防ぐために、React イベントで関数を呼び出すことができます。これは React クラスコンポーネントでも同様です。
function App() { ... return ( <Fragment> + <form onSubmit={event => { setUrl(`http://hn.algolia.com/api/v1/search?query=${query}`); + event.preventDefault(); + }}> <input type="text" value={query} onChange={event => setQuery(event.target.value)} /> <button type="submit">Search</button> </form> {isError && <div>Something went wrong ...</div>} ... </Fragment> ); }これで送信ボタンを押してもブラウザがリロードすることはなくなりました。これまで通り動作しますが、今回はネイティブのインプットフィールドとボタンの組み合わせの代わりにフォームを使用します。キーボードの「エンター」キーを押すこともできます。
データ取得用カスタムフック
データ取得用のカスタムフックを抽出するため、インプットフィールドに属している query state を除く、ローディングインジケータやエラーハンドリングなどデータ取得に関する全てを独自の関数に移動させます。App コンポーネントで使われるその関数から全ての必要な変数を返すことを忘れないでください。
+ const useHackerNewsApi = () => { const [data, setData] = useState({ hits: [] }); const [url, setUrl] = useState( 'http://hn.algolia.com/api/v1/search?query=redux', ); const [isLoading, setIsLoading] = useState(false); const [isError, setIsError] = useState(false); useEffect(() => { const fetchData = async () => { setIsError(false); setIsLoading(true); try { const result = await axios(url); setData(result.data); } catch (error) { setIsError(true); } setIsLoading(false); }; fetchData(); }, [url]); + return [{ data, isLoading, isError }, setUrl]; + }これで新しいフックが App コンポーネントから利用できるようになりました。
function App() { const [query, setQuery] = useState('redux'); + const [{ data, isLoading, isError }, doFetch] = useHackerNewsApi(); return ( <Fragment> <form onSubmit={event => { + doFetch(`http://hn.algolia.com/api/v1/search?query=${query}`); event.preventDefault(); }}> <input type="text" value={query} onChange={event => setQuery(event.target.value)} /> <button type="submit">Search</button> </form> ... </Fragment> ); }初期 state も設定可能です。単純に新しいカスタムフックに値を渡します。
import React, { Fragment, useState, useEffect } from 'react'; import axios from 'axios'; + const useDataApi = (initialUrl, initialData) => { + const [data, setData] = useState(initialData); + const [url, setUrl] = useState(initialUrl); const [isLoading, setIsLoading] = useState(false); const [isError, setIsError] = useState(false); useEffect(() => { const fetchData = async () => { setIsError(false); setIsLoading(true); try { const result = await axios(url); setData(result.data); } catch (error) { setIsError(true); } setIsLoading(false); }; fetchData(); }, [url]); return [{ data, isLoading, isError }, setUrl]; }; function App() { const [query, setQuery] = useState('redux'); + const [{ data, isLoading, isError }, doFetch] = useDataApi( + 'http://hn.algolia.com/api/v1/search?query=redux', + { hits: [] }, ); return ( <Fragment> <form onSubmit={event => { doFetch( `http://hn.algolia.com/api/v1/search?query=${query}`, ); event.preventDefault(); }} > <input type="text" value={query} onChange={event => setQuery(event.target.value)} /> <button type="submit">Search</button> </form> {isError && <div>Something went wrong ...</div>} {isLoading ? ( <div>Loading ...</div> ) : ( <ul> {data.hits.map(item => ( <li key={item.objectID}> <a href={item.url}>{item.title}</a> </li> ))} </ul> )} </Fragment> ); } export default App;カスタムフックでのデータ取得については以上になります。フック自体は API について何も知りません。全てのパラメータを外部から受け取り、data、loading や error のような state を必要に応じて管理するだけです。リクエストを処理し、データ取得用のカスタムフックとして data をコンポーネントに返します。
データ取得用 reducer フック
これまで様々なステートフックで、データ取得のための data、loading や error state を管理してきました。しかし、どういうわけかこれら全ての state は、自身のステートフックでまとめて管理されていますが、これらが同じ関心事を持っているためです。ご覧のとおり、これらは全てデータ取得関数内で利用されています。state をまとめるかどうかの良い指標は、逐次実行される(例.
setIsError
とsetIsLoading
)かどうかです。これら全3種をReducerフックで統合してみましょう。Reducer フックは state オブジェクトとその更新関数を返します。dispatch 関数と呼ばれるその関数は、type と任意の payload を持つ action を引数に取ります。この全ての情報は、実際の reducer 関数に使われ、以前の state、action の任意の payload と type から新しい state を生成します。コード内でどのように動作するのか見ていきましょう。
import React, { Fragment, useState, useEffect, + useReducer, } from 'react'; import axios from 'axios'; + const dataFetchReducer = (state, action) => { + ... + }; const useDataApi = (initialUrl, initialData) => { const [url, setUrl] = useState(initialUrl); + const [state, dispatch] = useReducer(dataFetchReducer, { + isLoading: false, + isError: false, + data: initialData, + }); ... };Reducer フックは reducer 関数と初期 state をパラメータとして取ります。今回は、引数として渡す data、loading そして error state は変更されていませんが、それらは単一のステートフックではなく、1つの reducer フックで管理される1つの state オブジェクトに集約されています。
const dataFetchReducer = (state, action) => { ... }; const useDataApi = (initialUrl, initialData) => { const [url, setUrl] = useState(initialUrl); const [state, dispatch] = useReducer(dataFetchReducer, { isLoading: false, isError: false, data: initialData, }); useEffect(() => { const fetchData = async () => { + dispatch({ type: 'FETCH_INIT' }); try { const result = await axios(url); + dispatch({ type: 'FETCH_SUCCESS', payload: result.data }); } catch (error) { + dispatch({ type: 'FETCH_FAILURE' }); } }; fetchData(); }, [url]); ... };データ取得時に、dispatch 関数から reducer 関数に情報を送ることができます。dispatch 関数によって送られるオブジェクトは、必須の
type
プロパティと任意のpayload
プロパティを持ちます。type はどの state の遷移が必要であるかを指示し、payload は新しい state を生成するための追加情報として reducer 関数に渡されます。最終的に、3種の state の遷移が必要となります。データ取得処理の初期化、成功の通知そしてエラーの通知です。カスタムフックの末尾で、以前のように state を返しますが、これは state オブジェクトが独立したものではなくなったためです。このように
useDataApi
カスタムフックを呼び出すことでdata
、isLoading
そしてisError
にアクセスできるようになります。const useDataApi = (initialUrl, initialData) => { const [url, setUrl] = useState(initialUrl); const [state, dispatch] = useReducer(dataFetchReducer, { isLoading: false, isError: false, data: initialData, }); ... + return [state, setUrl]; };
大事なことを言い忘れましたが、reducer 関数が未実装です。
FETCH_INIT
、FETCH_SUCCESS
、FETCH_FAILURE
の3種の state 遷移に応じた振る舞いが必要です。それぞの state 遷移は新しい state オブジェクトを返します。switch case 文で実装する方法について見ていきましょう。const dataFetchReducer = (state, action) => { + switch (action.type) { + case 'FETCH_INIT': + return { ...state }; + case 'FETCH_SUCCESS': + return { ...state }; + case 'FETCH_FAILURE': + return { ...state }; + default: + throw new Error(); + } };
reducer 関数は引数を経由して現在の state と次の action にアクセスできます。現状の switch case 文では、各 state 遷移は前の state を返すだけです。分割代入は state オブジェクトをイミュータブルに保つために使用されます。つまり、state は決して直接変更されることはなく、ベストプラクティスに従うことを強制します。それでは、state 遷移ごとに state を更新するために、現在の state から返されるプロパティのいくつかを上書きしましょう。
const dataFetchReducer = (state, action) => { switch (action.type) { case 'FETCH_INIT': return { ...state, + isLoading: true, + isError: false }; case 'FETCH_SUCCESS': return { ...state, + isLoading: false, + isError: false, + data: action.payload, }; case 'FETCH_FAILURE': return { ...state, + isLoading: false, + isError: true, }; default: throw new Error(); } };これで action の type によって決められた各 state 遷移が前の state と任意の payload に基づいた新しい state を返します。たとえば、リクエスト成功時には、payload が新しい state オブジェクトの data に設定されます。
結論として、Reducer フックは state 管理のこの部分が独自のロジックでカプセル化されていることを保証します。action type と任意の payload を与えることで、常に予測可能な state の更新が発生します。さらに、無効な state に遭遇することは決してありません。たとえば、以前は誤って
isLoading
とisError
を true にすることも可能でした。この場合は UI に何を表示する必要があるでしょうか?reducer 関数によって定義された各 state 遷移は有効な state オブジェクトになりますので、もう心配無用です。副作用フックでのデータ取得キャンセル
React におけるよくある問題として、コンポーネントが既にアンマウントされている(例. React Router で別ページに遷移した時)にも関わらず、コンポーネントの state は設定されていることがあります。この問題については、様々なシナリオでアンマウントされたコンポーネントの state が設定されるのを防ぐ方法で詳細を書きました。データ取得用のカスタムフックで state を設定できないようにする方法についても見てみましょう。
const useDataApi = (initialUrl, initialData) => { const [url, setUrl] = useState(initialUrl); const [state, dispatch] = useReducer(dataFetchReducer, { isLoading: false, isError: false, data: initialData, }); useEffect(() => { + let didCancel = false; const fetchData = async () => { dispatch({ type: 'FETCH_INIT' }); try { const result = await axios(url); + if (!didCancel) { dispatch({ type: 'FETCH_SUCCESS', payload: result.data }); + } } catch (error) { + if (!didCancel) { dispatch({ type: 'FETCH_FAILURE' }); + } } }; fetchData(); + return () => { + didCancel = true; + }; }, [url]); return [state, setUrl]; };全ての副作用フックには、コンポーネントがアンマウントされた時に実行されるクリーンアップ関数を付与することができます。クリーンアップ関数はフックから返される関数です。この例では、
didCancel
と呼ばれる boolean フラグを使って、データ取得ロジックにコンポーネントの状態(マウントされた/アンマウントされた)を知らせます。コンポーネントがアンマウントされた時は、フラグがtrue
に設定され、データ取得が非同期で解決された後にコンポーネントの state を設定できないようにしています。注:実際にデータ取得がキャンセルされるわけではありません。これはAxios Cancellationで実現可能ですが、アンマウントされたコンポーネントの state 遷移は実行されなくなっています。個人的に Axios Cancellation の API はベストとは思えないので、この boolean フラグで state の更新を同じように防いでいます。
React のデータ取得で React Hooks によってステートと副作用を扱う方法を学びました。クラスコンポーネント(および関数コンポーネント)でレンダープロップやHoCsを使ってデータ取得する方法についても興味があれば、私の他の記事を最初から読んでみてください。
また、この記事が React Hooks についての学習や、実際のシナリオでの活用法として役立つことを願っています。
- 投稿日:2019-06-07T15:06:08+09:00
React hooksのuseReducerをuseStateから作る
はじめに
前の記事で、 useReducer から useState を作ってみましが、逆はできるのだろうかと挑戦してみました。
useReducerを作る
結果的には次のようになりました。
import { useCallback, useMemo, useState } from "react"; export const useReducer = (reducer, initialArg, init) => { const [state, setState] = useState( init ? () => init(initialArg) : initialArg ); const dispatch = useCallback( action => { setState(prev => reducer(prev, action)); }, [reducer] ); return useMemo(() => [state, dispatch], [state, dispatch]); };lazy initializationのあたりが期待通りに実装できているか、確信はありません。useStateの方のシグネチャがシンプルなので、何か違いがあるような気もするのですが。
おわりに
useReducerからuseStateを作って、そのuseStateからまたuseReducerを作って、が永遠にできますね。何か見落としがあったらすみません。
- 投稿日:2019-06-07T14:00:48+09:00
React hooksのuseStateをuseReducerから作る
はじめに
React hooksのドキュメントでは、 useState は basic hooks として、 useReducer は additional hooks として分類されています。ところが、実際の実装としては useReducer が基本実装で useState がラッパー実装となっているようで、プルリクなどでは useReducer のみが修正されていることがあるようでした。
useStateのラッパーを書いてみる
Reactのソースコードを読んだわけではありませんが、useStateをuseReducerのラッパーとして書いてみたらどうなるか試してみます。
import { useReducer } from "react"; const isFunction = f => typeof f === "function"; const reducer = (state, action) => isFunction(action) ? action(state) : action; export const useState = initialState => { const args = isFunction(initialState) ? [undefined, initialState] : [initialState]; return useReducer(reducer, ...args); };思ったより、簡単にできました。
おわりに
繰り返しになりますが、Reactの実装を再現したものではありません。あくまで勉強用です。本物実装はさらにエラー処理や開発時チェックなどが追加されているかもしれません。
- 投稿日:2019-06-07T13:14:01+09:00
React hooksとuse-immerを使いながらTypescriptでreduxっぽいことをする
use-immer
https://github.com/immerjs/use-immerテストrepo
https://github.com/github0013/react-hooks-immer-redux-typescript
yarn && yarn develop
で動きます
- reducerを作る
リンク1- use-immerの
useImmerReducer
を使ってstateとdispatchを作る
リンク2- contextを使って、stateとdispatchを保持する
リンク3
https://reactjs.org/docs/context.html
- useContextを使ってを参照する
リンク4
https://reactjs.org/docs/hooks-reference.html#usecontext問題
通常
reducer
内ではstate
をimmutable
にする為に、switch (action.type) { case "some_action_name": return [...state, abc: new_value]などとする必要がある。結構面倒
useImmerReducerだと...
これをuse-immerの
useImmerReducer
を使う事でswitch (action.type) { case "some_action_name": // 直接stateをいじれる上に、returnする必要すら無い state.abc = new_valueとして、処理が簡単にかける。
- 投稿日:2019-06-07T08:31:09+09:00
「ボタンを押してコンテンツを切り替えるシンプルなやり方」を React + TypeScirpt にしてみる
何なのこれは?
@ngron さんの
を React、しかも TypeScript で
馬鹿げた冗長なコードにしてみる実験。デモ
See the Pen jojBZm by Kenta Konno (@sprout2000) on CodePen.
コードの中身
index.html<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> </head> <body> <div id="root"></div> </body> </html>index.tsximport React from 'react'; import ReactDOM from 'react-dom'; import App from './App'; import './index.css'; ReactDOM.render(<App />, document.getElementById('root'));App.tsximport React from 'react'; import './App.css'; interface State { status: boolean; } const DoneContent = (): JSX.Element => { return ( <div> <li>完了 1</li> <li>完了 2</li> <li>完了 3</li> </div> ); }; const PendingContent = (): JSX.Element => { return ( <div> <li>未完了 1</li> <li>未完了 2</li> <li>未完了 3</li> </div> ); }; class App extends React.Component { public state: State = { status: true, }; public handleOnToggle = (): void => { this.setState({ status: !this.state.status }); }; public render(): JSX.Element { return ( <div> <button onClick={this.handleOnToggle} style={{ color: this.state.status ? 'blue' : 'black' }}> 完了 </button> <button onClick={this.handleOnToggle} style={{ color: this.state.status ? 'black' : 'blue' }}> 未完了 </button> { (this.state.status) && <DoneContent /> } { (!this.state.status) && <PendingContent /> } </div> ); } }index.csshtml { margin: 0; padding: 0; } body { margin: 0; padding: 0; background-color: #ffffff; } #root { margin: 0; padding: 0; }App.cssbutton { margin: 10px; padding: 10px 20px; font-size: 20px; border-radius: 20px; } li { margin: 10px; font-size: 23px; }
- 投稿日:2019-06-07T08:23:41+09:00
ReactでFontAwesomeを簡単に使う
はじめに
ReactでFont Awesomeをscssで使おうとして、
上手くいかなくて調べていたら、すごくお手軽簡単な方法にたどり着いたので、
今更感がありそうですが、共有します。Font Awesome?
ざっくり言うとWebページで、
アイコンフォントが使えるようになるCSSフレームワークです。
例えばSNSのアイコンなんかを画像で用意しなくても使えるので、
お手軽で軽量です。
無料と有料があり、有料だと使えるアイコンの数が増えます。環境
windows10
Node.js v8.12
React.js v16.8.6前提
- 今回Font Awesome自体の使い方は記載しません
- この方法ではFont Awesomeバージョン4になります
- バージョン5以降を使いたい場合は、公式からDLして設置します
- react専用のnpmパッケージもありますが、今回はふれません(あまりロックインしたくないので)
- 今回のnpmは3年更新されてないようです。そこは気がかり
- とは言え、週間60万もDLされてるのでメジャー且つ人気っぽいです(2019.6.7現在)
Font Awesome公式はこちら
基本的な使い方はこちら
具体的な使い方はこちら
npmはこちら
React専用はこちら準備
適当にReactのプロジェクトを作ります。
$ create-react-app fontawesome-hello
※後述のサンプルは、余計なコードを消していますが、
これで作られたコードにそのまま記述しても使えます。インストール
$ npm install --save font-awesomeコード書き換え
index.js
一番TOPのコンポーネントです。
ここに先ほどインストールしたcssを読み込みます。import React from 'react'; import ReactDOM from 'react-dom'; import App from './App'; import * as serviceWorker from './serviceWorker'; // ★これを追加するだけ import 'font-awesome/css/font-awesome.min.css'; ReactDOM.render(<App />, document.getElementById('root')); serviceWorker.unregister();App.js
実際にfont Awesomeを利用するコンポーネントです。
公式の使い方の通り、classに設定して利用します。import React, { Component } from 'react'; // 別にクラスでなくても良い class App extends Component { constructor(props) { super(props); } //★ <i className="fa fa-thumbs-up">でグッドアイコンが表示される render() { return ( <div> hello font awesome<i className="fa fa-thumbs-up"></i> </div> ); } //アイコンだけだとなんとなく寂しかったので文字も出しているが、今回の話と関係ない } export default App;実行結果
一番右にグッドアイコンが出てますね。
成功です!所感
過去にいろいろ試して四苦八苦したのですが、
1コマンドで用意できてしまうのはすごく楽ですね。
この感じならAnguraやVue.jsでも使えそうですし、
node.jsのサーバサイドレンダリングでもいけそうですね!
(試してませんが)参考
Get Sass and Font Awesome up and running on your Create React App in 5 minutes
- 投稿日:2019-06-07T04:43:39+09:00
最小構成で始める@ionic/react
はじめに
今年のはじめにIonic 4がリリースされました。
Ionic 4では、UIコンポーネントがWebComponentsで再実装されたことにより、ReactやVueでもIonicが利用できるようにななっています。
ということで、早速React版の @ionic/react を動かしてみたいと思います。
(現在バージョンが0.05なので変更される可能性は大いにありますのでご注意ください。)@ionic/react を利用するためにはWebpackでいくつかの設定が必要です。
Ionic CLIやcreate-react-appを利用することで、それらが設定済みの状態で@ionic/reactの利用ができます。
(どちらも内部的には react-scripts を使用しています。)しかし、それらの環境では不必要なパッケージもたくさん導入されてしまうでしょう。
ビルド環境を自分でカスタマイズしたい人や、既存のReactアプリにIonicを組み込みたい人向けに、最小構成で@ionic/reactが動く環境を作ってみます。パッケージのインストール
以下のようにパッケージを初期化し、必要なパッケージをインストールします。
$ npm init -y $ npm i @ionic/react react react-dom react-router@4 react-router-dom@4 $ npm i -D @babel/core @babel/preset-react @svgr/webpack babel-loader css-loader style-loader url-loader webpack webpack-cli webpack-dev-serverWebpackの設定
以下のように
webpack.config.js
を書きます。webpack.config.jsconst path = require('path') module.exports = { module: { rules: [ { test: /\.js$/, use: { loader: 'babel-loader', options: { presets: ['@babel/preset-react'] } }, include: [ path.resolve(__dirname, 'src') ] }, { test: /\.css$/, use: [ 'style-loader', { loader: 'css-loader', options: { importLoaders: 1 } } ] }, { test: /\.svg$/, use: [ '@svgr/webpack', 'url-loader' ] } ] }, entry: { bundle: './src/index' }, output: { path: path.join(__dirname, 'public'), filename: '[name].js' }, devServer: { historyApiFallback: true, contentBase: path.join(__dirname, 'public') }, mode: process.env.NODE_ENV || 'development' }アプリの実装
こんな感じでアプリを実装します。
説明を簡単にするために1ファイルだけの簡単なものです。
Reactに使い慣れた方であれば普通のコンポーネントライブラリとして利用できるでしょう。src/index.jsimport '@ionic/core/css/core.css' import '@ionic/core/css/ionic.bundle.css' import React from 'react' import { render } from 'react-dom' import { BrowserRouter as Router, Route, Switch } from 'react-router-dom' import { IonApp, IonContent, IonFab, IonFabButton, IonHeader, IonIcon, IonItem, IonList, IonTitle, IonToolbar } from '@ionic/react' const MainPage = () => { const [items, setItems] = React.useState(['A', 'B', 'C']) return <> <IonContent> <IonList> { items.map((item, i) => { return <IonItem key={i}>{item}</IonItem> }) } </IonList> </IonContent> <IonFab vertical='bottom' horizontal='end' slot='fixed'> <IonFabButton onClick={() => { const newItems = Array.from(items) newItems.push('new item') setItems(newItems) }} > <IonIcon name='add' /> </IonFabButton> </IonFab> </> } const App = () => { return <Router> <IonApp> <IonHeader> <IonToolbar> <IonTitle>Hello @ionic/react</IonTitle> </IonToolbar> </IonHeader> <Switch> <Route path='/' component={MainPage} /> </Switch> </IonApp> </Router> } render(<App />, document.getElementById('content'))HTMLファイルはこんな感じです。
public/index.html<!DOCTYPE html> <html> <head> <meta charset="utf-8"/> <meta name="viewport" content="width=device-width"> <title>@ionic/react minimum start</title> </head> <body> <div id="content"/> <script src="bundle.js"></script> </body> </html>以下のコマンドで実行します。
$ npx webpack-dev-server
http://localhost:8080/ にアクセスすると以下のように表示されます。
(Chromeのモバイルエミュレーションでの画面です。)おわりに
最後に、わかった範囲で @ionic/react を正しく動かすためのポイントをまとめます。
- 現時点では
react-router
とreact-router-dom
のバージョンは4@ionic/core/css/core.css
と@ionic/core/css/ionic.bundle.css
を読み込む
- 今回は
style-loader
とcss-loader
を使用しましたが、結果的に必要なcssが読み込めれば手段は何でもOKですionicons
内のSVGファイルを正しく読み込む
IonIcon
の動作に必要です@svgr/webpack
とurl-loader
を使うことで、importしたSVGがData URI形式で扱われますfile-loader
でも代替可能ですが、全てのSVGファイルがアセットとして書き出されますもっと良い方法があったら教えてください。
Ionicは元々Angularをベースに作られていました。
Ionic 4では、これぐらいの最小限のReactプログラムでIonicを利用できるようになっていて素晴らしいですね。参考情報
- 投稿日:2019-06-07T04:20:22+09:00
Apollo Client + React Native + TypeScript で辛かった話
「辛かった」と書いていますが、実際にはまだ「辛い」です。
Apollo の導入を検討している方の参考になれば幸いです。概要
- React Native + TypeScript のプロジェクトで Apollo Client を使ってみた
- すげー便利!って感じで最初は始まった
- だんだん、 Apollo のキャッシュが効きすぎて、「画面更新されない」問題にぶち当たる
- そのうち、 Apollo のキャッシュを無効化する作業に入る
- Apollo 使ってる意味なくね、ってなる
MobX
とfetch
ベースのライブラリky
ベースで、全部書き換える(進行形)Apollo Client とは
https://www.apollographql.com/
Apollo Client は、 Apollo という GraphQL 界の重鎮が作っているフレームワークのクライアントライブラリです。
Apollo Server や Apollo CLI などもありますが、 Apollo Client は React などの JavaScript ライブラリと組み合わせて使うライブラリになります。
Apollo Client の特徴
- コンポーネントが マウントされた時に、データ取得が走る (ここ重要)
__typename
とid
によるキャッシュにより、無駄なデータの取得をしない
- フィールドが追加された場合は、再度取得する
- クエリ時に
fetchPolicy: 'no-cache'
などとすると、強制的に再取得する- ミドルウェア的なものをかませて、エラー時の処理などを追加できる
- Redux や MobX などに代わる、
apollo-link-state
というライブラリと組み合わせると、最強(になるはず)具体的には、こんな感じです。
UserDetail.tsximport { graphql } from 'react-apollo' import gql from 'grpahql-tag' import { ActivityIndicator, View, Text } from 'react-native' const GET_USER = gql` query getUser { user { id name } } ` // 実際には、 $WithGraphQL<User> みたいにして DRY にする interface WithUser { data: { user: { id: number name: string birthday: string } } } export function UserDetail({ data }: WithUser) { if (data.loading) { return <ActivityIndicator /> } return ( <View> <Text>{data.user.id}</Text> <Text>{data.user.name}</Text> </View> ) } export default grpahql(GET_USER)(UserDetail)辛くなっていく過程
最初
ブラウザの React で使ったことがあり、今回の React Native のプロジェクトでも使うことにしました。
ただ、ブラウザの React で苦しんだのは、共通ロジックです。コンポーネント間で共有したいロジックがある場合(例: ユーザーの生年月日をパースして、年齢を出す)、コンポーネントにべた書きすると、ロジックが各コンポーネントに分散するので、微妙です。
そこで、下記のようなファイルを作成してまとめれば、いける、と判断しました。
entities/User.tsexport function getAge(user: User) { // }Apollo でシリアライゼーション層みたいなのができればいいんですけどね。結構調べたけど、無理っぽいですね。
結果的に、これは、何とかなりました。実際には、
Partial<User, 'birthday'>
などとして、汎用性を高めています。https://github.com/apollographql/apollo-tooling#apollo-clientcodegen-output
すげー便利
上記の書き方を採用すると、コンポーネントはちょっと厚くなりますが、全体的なコード量が減るので、便利になります。
特に Redux 等のライブラリで状態管理をするよりも、格段にコード量は減ります。また、
apollo-codegen
という神ツールを使うと、 GraphQL から TypeScript の型定義を作成できるので、工数がかなり減らせます。静的な GraphQL のチェックにもなる。 CI で回せばなお良い。
これは今でも使っています。Apollo のキャッシュ に苦しみ始める
キャッシュ。これが難しい。
単純なユーザー情報の更新を考えます。
Show
コンポーネントで、ユーザーが自身の情報を見るEdit
コンポーネントで、ユーザーが Update 系の行動をする (mutation
)Show
コンポーネントで、もう一度自身の情報を見るこれは比較的簡単です。
Edit
コンポーネントでmutate
する時に、ユーザー情報を全て fetch すれば良いからです。
ただ、これだけでも、
- 取得フィールドを
Show
とEdit
でほぼ完全に一致させる- mutation 側のクエリで
fetchPolicy:
no-cache`という注意点があります。(これエラー出ないから、気づかないんだよな・・・)
次に、もっと複雑な状態変化を考えます。ちょっと具体的です。
Show
コンポーネントで、ユーザーが自身の情報「と」閲覧可能な動画が表示される
- この時、ユーザーは15歳で登録している、とする
Edit
コンポーネントで、ユーザーが生年月日を変えて、30歳になる- サーバーサイドで、ユーザーの別の状態が更新される
- 例: 生年月日を変えたら、エロ動画も表示できるようになる
Show
コンポーネントで、ユーザーが自身の情報「と」閲覧可能な動画が表示される
- ここで、エロ動画を表示させたい!
※エロ動画はただの例です。
この時、動画を取得するクエリを 再度実行(refetch) しないといけないですが、 React Native + Apollo だとこれが難しい。
理由としては、同じ React でも、ブラウザとスマホアプリの違いとして、ブラウザでは「画面リロード」という最終手段があるのに対し、スマホアプリでは、アプリを落とさないといけない、というのがあります。
対策として、強制的にキャッシュを無効化し始めます。
graphql(GET_USER_AND_MOVIES, { fetchPolicy: 'no-cache', })(UserDetail)一見これで解決しそうです。実際、ブラウザでは、画面変更により再度コンポーネントがマウントされるので、GraphQLが再度実行され、問題ありません。
が。アプリの場合、
componentDidMount
が、アプリでは Stack を重ねるだけなので発火しないことがあります。React Navigation というライブラリでルーティングを管理していたのですが、最終的に、 下記のようなコードを随所に書き始めるようになりました。
componentDidMount() { const { data, navigation } = this.props navigation.addListner('willFocus', () => data.refetch()) }ただし、こうしても、ナビゲーションの種類(
TabNavigation
なのか、StackNavigation
なのか、更にその親子関係)によって挙動が代わるので、それも意識しながらプログラミングする必要が出てきます。また、他にも
refetch
を行った時はdata.loading
がfalse
になる
- そのためには
notifyNetworkStatus: true
オプションを指定する- でも指定しても
false
のままになる(ことがある?)refetch
時に、関連コンポーネントの状態が更新されるので、画面の後ろ側に出ているコンポーネント側でエラーになったりする
- デバッグがしんどい
など、マイナーだけど重要な仕様に悩まされました。
コンポーネントがエラーになったり、再取得時にローディングが出せない。
せっかく年齢を偽って、エロ動画が見れるようになったのに、15歳の少年はそれに気づかない可能性がある。これでは運営も少年も、悶々とした日々を送ることになってしまいます。
対策としては、年齢を変更した時に、 Redux 等で別でステート管理し、そちらで GraphQL リクエストを発火させ、キャッシュを更新させる、という方法が思いつきます。
が、あまり直感的ではないコードになりそうです。Apollo のキャッシュは優秀ですが、 React Native では致命傷を負いかねない、という結論になりました。
どうしたか
react-apollo を捨てて、
MobX
に置き換えました。Redux でも何でも良いんですが、
mapStateToProps
等の記述量と TypeScript との相性を考えた結果です。MobX で記述すると、下記のメリットがありました。
- キャッシュ削除の処理をストア側で一元管理できる
- ストアに状態操作ロジックが集中するので、コンポーネントが薄くなる
- MobX ではストアはただのオブジェクトなので、テストが書きやすい
また、未だに
apollo-client
というライブラリで GraphQL を取得していますが、これもky
というライブラリでやろうと思っています。https://github.com/sindresorhus/ky
これは Apollo から距離を置きたい気持ちがあります。
が、一旦、下記のような関数を使ってリクエストしています。
export const apolloClient = new ApolloClient({ link: ApolloLink.from(links), cache, }) export function query<T, V extends {} = {}>(query: any, variables?: V) { return apolloClient.query<T, V>({ fetchPolicy: 'no-cache', query, variables, }) } export function mutate<T, V extends {} = {}>(mutation: any, variables?: V) { return apolloClient.mutate<T, V>({ fetchPolicy: 'no-cache', mutation, variables, }) }考察
react-apollo はデータ・ドリブンなコンポーネントが作れて良いのですが、アプリ全体に関わるようなロジックは、 Apollo で管理すると荷が重いのではないか、と考えています。
もっと上手くキャッシュを扱う方法があるとは思いますが、現時点での私の能力では太刀打ち出来ないものでした。現在も react-apollo は使っていますが、下記のようなユースケースに絞っています。
- アプリ内で変更が滅多に起こらない部分
- 例: カテゴリ一覧。素人、熟女、清楚、ロリ、など
- データがコンポーネント内に完全に閉じている部分
- 例: お気にいりのエロ動画。追加と削除しかなく、他の部分に影響を与えない
以上です。ちなみに私は26歳です。
- 投稿日:2019-06-07T00:46:04+09:00
ReactでシンプルなDIを行う
Reactを使っているとき、Componentにレンダリング以外のロジックを混入させないために、他のモジュールに処理を切り出すということはよく行われると思います。
また、テストの時だけComponentの依存モジュールの実装を差し替えたい、というのもよくあることです。
ReactのuseContext
フックを使えばこれらがシンプルに解決できそうだ、というのがこの記事の趣旨です。そして自分の中でのユースケースに合わせ、ライブラリを作りました。
基本的にTypeScriptで利用することを想定しています。
@mozisan/diact
(とりあえずコードを書いてGitHub上にpushした程度なのでREADMEも書いていませんが…)$ npm install @mozisan/diact以下はこのライブラリを使ってDIを行う方法を紹介します。
(ライブラリのソースを見ればわかる通り、ライブラリでラッピングしている処理はとても単純なので、あえてライブラリを使わず直接useContext
を使っても同じようなことはできます。)DIコンテナを作る
import { createDIContainer } from '@mozisan/diact'; type Foo = { readonly doFoo: () => void; }; type Bar = { readonly doBar: () => void; }; type Deps = { readonly foo: Foo; readonly bar: Bar; }; const { DepsProvider, useDeps } = createDIContainer<Deps>(); export { DepsProvider, useDeps };
createDIContainer()
によってDepsProvider
とuseDeps
を得ます。
DepsProvider
は実際に依存モジュールを注入するComponentで、useDeps
はその注入された依存モジュールを子Componentで利用するためのカスタムHookです。依存モジュールを参照する
先ほど得た
useDeps
を使います。import React from 'react'; import { useDeps } from 'path/to/di-container'; export const App = () => { const { foo, bar } = useDeps(); return ( <div> <button onClick={foo.doFoo}>Do foo</button> <button onClick={bar.doBar}>Do bar</button> </div> ); };依存モジュールを注入する
先ほど得た
DepsProvider
を使います。import React from 'react'; import ReactDOM from 'react-dom'; import { DepsProvider } from 'path/to/di-container'; import { App } from 'path/to/app'; const foo = { doFoo: () => { console.log('foo'); }, }; const bar = { doBar: () => { console.log('bar'); }, }; ReactDOM.render( <DepsProvider deps={{ foo, bar }}> <App /> </DepsProvider>, document.getElementById('container'), );(Optional) Componentに必要なモジュールだけを参照するようにする
DepsProvider
は、Component群から参照される全ての依存モジュールを注入しなければいけません。
そのため、useDeps()
から得られるモジュールの一部は、あるComponentにとって不要なことがあります。
そこで、Componentごとに必要とする依存モジュールを絞り込む機能も紹介します。
(と言っても、これはComponentのテストのために導入した機能であり、これを使うとComponentの実装の観点で何かが便利になるというわけではありません。)DIコンテナを作る
実は
createDIContainer()
から、createLocal
という関数も提供されているので、これをexport
しておきます。const { DepsProvider, useDeps, createLocal } = createDIContainer<Deps>(); export { DepsProvider, useDeps, createLocal };依存モジュールを絞り込んで参照する
先ほど得た
createLocal
を使います。import React from 'react'; import { createLocal } from 'path/to/di-container'; const { useLocalDeps } = createLocal((deps) => ({ foo: deps.foo })); export const Foo = () => { const { foo } = useLocalDeps(); return ( <div> <button onClick={foo.doFoo}>Do foo</button> </div> ); };import React from 'react'; import { createLocal } from 'path/to/di-container'; const { useLocalDeps } = createLocal((deps) => ({ bar: deps.bar })); export const Bar = () => { const { bar } = useLocalDeps(); return ( <div> <button onClick={bar.doBar}>Do bar</button> </div> ); };依存モジュールを注入する
これは同じく、先ほど得た
DepsProvider
を使います。import React from 'react'; import ReactDOM from 'react-dom'; import { DepsProvider } from 'path/to/di-container'; import { Foo } from 'path/to/foo'; import { Bar } from 'path/to/bar'; const foo = { doFoo: () => { console.log('foo'); }, }; const bar = { doBar: () => { console.log('bar'); }, }; ReactDOM.render( <DepsProvider deps={{ foo, bar }}> <Foo /> <Bar /> </DepsProvider>, document.getElementById('container'), );(Optional) テスト時に、
useLocalDeps()
で参照されるモジュールだけを注入する前述の通り、
createLocal()
はComponentのテストのために作った機能です。
createLocal()
の返り値からはLocalDepsProvider
というComponentも得られ、これを使って次のようにテストを書けます。import React from 'react'; import { createLocal } from 'path/to/di-container'; const { LocalDepsProvider, useLocalDeps } = createLocal((deps) => ({ foo: deps.foo })); export { LocalDepsProvider as FooDepsProvider }; export const Foo = () => { const { foo } = useLocalDeps(); return ( <div> <button onClick={foo.doFoo}>Do foo</button> </div> ); };import { Foo, FooDepsProvider } from 'path/to/foo'; describe('Foo', () => { it('works', () => { const fooMock = { doFoo: () => { console.log('foo'); }, }; render( <FooDepsProvider deps={{ foo: fooMock }}> <Foo /> </FooDepsProvider> ); }); });テスト時に
DepsProvider
を直接使っても構いませんが、全ての依存モジュールのモックをいちいち注入するのは面倒ですし、テストコードにノイズが増えてしまいます。
createLocal()
から得られるLocalDepsProvider
を使うと、必要なモジュールのモックだけ注入すればよいので、テストがシンプルになります。おわりに
ライブラリ自体がシンプルなので、使い方もシンプルに収まります。
createLocal()
を使わなくても結構ですし、なんならComponentごとにcreateDIContainer()
によってDIコンテキストを作り、アプリケーション全体をDepsProvider
で包むことはしないという方法もあるでしょう。
このライブラリを使うとも使わずとも、Reactにおけるモジュール設計の参考になれば幸いです。