- 投稿日:2021-12-03T23:20:30+09:00
【React】React Queryの基礎知識をまとめました(Stale, Cache, Prefetchingなど)
はじめに 新しい現場ではReactの状態管理にReduxを使っているのですが、Action CreatorやReducerなど、コード量の多さに辟易してしまうことがしばしばあります。 そんな悩みを解決する状態管理ライブラリとしてReact Queryが流行っているらしいので、このたび勉強してみることにしました。 以前書いた状態管理の記事もあわせてご覧いただければ幸いです。 React Queryとは React Queryの大きな特徴として、サーバから取得したデータをクライアントでキャッシュとして保管できることが挙げられます。 また、新しいデータを取得したときにいつキャッシュを更新するか、なども管理することができます。 初期導入 npm install react-queryでパッケージをインストールした後、クエリとキャッシュを管理するためのqueryClientを作成し、RootのコンポーネントをQueryClientProviderでラップします。 QueryClientProviderはキャッシュやクライアント設定を下のコンポーネントに与えたり、値としてqueryClientを取得する役割をもちます。 あとはラップされたコンポーネント内でuseQuery()というHooksを使うことで、クエリを行うことができます。 App.jsx import { QueryClient, QueryClientProvider } from 'react-query'; import { ReactQueryDevtools } from 'react-query/devtools'; const queryClient = new QueryClient(); function App() { return ( <QueryClientProvider client={queryClient}> <div className="App"> <Posts /> </div> <ReactQueryDevtools /> </QueryClientProvider> ); } ラップされたコンポーネントの中にReactQueryDevToolというものがありますが、これはReact Queryで管理しているデータの変化を確認するための開発者用ツールです。 Fetching 以下のようなデータをフェッチする関数からuseQuery()でデータを取得してみます。 async function fetchPosts(pageNum) { const response = await fetch( `https://jsonplaceholder.typicode.com/posts?_limit=10&_page=${pageNum}` ); return response.json(); } useQuery()の第一引数にはkeyを、第二引数にはフェッチ関数をコールバックとして配置します。 すると、dataをはじめとした様々なプロパティを取得することができます。 const { data, isError, error, isLoading, isFetching } = useQuery( 'posts', () => fetchPosts(currentPage) ); isFetchingとisLoadingの違い isFetchingはクエリ関数がデータをフェッチするまでの状態のことをいい、isLoadingはisFetchingの状態に加えてキャッシュデータももっていない状態のことをいいます。 つまり、キャッシュをもっていればisLoadingはfalseとなり、isFetching ⊃ isLoadingといった関係になります。 例えば、訪問済みのページに再度訪れたとき、キャッシュがあるのでデータはちゃんと表示されていますが、下のReactQueryDevToolsの画面をみるとfetchingという状態になっていることがわかります。 Stale timeとCache timeの違い 以上の画面で、fetchingの右側にstaleとありますが、これもReact Queryのキャッシュ機構を理解する上で大事なキーワードとなります。 Stale timeというのは、キャッシュデータが古くなったとみなす時間のことをいいます。 Stale timeはデフォルトで0 sなのですが、以下のようにstaleTime: 1000などと設定することで、1000 ms以内に再訪問したページであればデータを新しいもの(fresh)とみなし、フェッチを行わずにキャッシュを利用するようになります。 1000 msを超えた場合にはキャッシュが使えなくなり、データを再フェッチします。 const { data, isError, error, isLoading, isFetching } = useQuery( 'posts', () => fetchPosts(currentPage), { staleTime: 1000, } ); 一方、Cache timeはデータをキャッシュする時間のことをいいます。 デフォルトの設定は5分(300000 ms)となっています。 staleTime: 0 cacheTime: 300000のとき、同じページを再訪問するとキャッシュデータが画面に表示されますが、staleTimeの時間が過ぎてキャッシュデータは古いものとみなされるため、バックグラウンドで再フェッチが実行されます。 useQuery()のkey useQuery()の第一引数に単一のkeyを与えるとき、再フェッチのトリガーとなるのは以下のようなケースです。 コンポーネントの再マウント ウィンドウの再フォーカス 再フェッチ関数の実行 これらのケースから外れてしまうと、クエリが実行されず再フェッチも行われません。 この問題を解決するためには、keyを依存配列として扱い、中身の値が変わったときにuseQuery()を実行するようにします。 例えば、ページネーションでcurrentPageが変わったときにuseQuery()を実行させるとしたら、以下のような記述となります。 const { data, isError, error, isLoading, isFetching } = useQuery( ['posts', currentPage], () => fetchPosts(currentPage), { staleTime: 2000, } ); Prefetching Prefetchingとは、予測されるデータをキャッシュに書き込んでおき、ページを開いてフェッチを行っている間、キャッシュデータを表示されるようにすることです。 useEffect()にprefetchQuery()を仕込んでおくことで、現在のページを開いたときに、次のページのデータを['posts', nextPage]のkeyにキャッシュしています。 const queryClient = useQueryClient(); useEffect(() => { if (currentPage < maxPostPage) { const nextPage = currentPage + 1; queryClient.prefetchQuery(['posts', nextPage], () => fetchPosts(nextPage) ); } }, [currentPage, queryClient]); Mutation useQuery()ではデータの取得処理を行いましたが、書込処理を行うためのHooksとしてuseMutation()というものもあります。 useQuery()との違いとしては以下のものがあります。 mutate関数を返す keyは不要 isLoadingはあるがisFetchingはなし デフォルトでリトライなし(クエリはデフォルト3回) 例えば、以下のようなデータ削除のための関数を考えます。 async function deletePost(postId) { const response = await fetch( `https://jsonplaceholder.typicode.com/postId/${postId}`, { method: 'DELETE' } ); return response.json(); } useMutation()からdeleteMutationを定義します。 const deleteMutation = useMutation((postId) => deletePost(postId)); Deleteボタンに仕込むことで、データの削除を実行することができます。 <button onClick={() => deleteMutation.mutate(post.id)}>Delete</button> 参考資料
- 投稿日:2021-12-03T23:14:33+09:00
毎日誰かのプルリクを脳死でマージするアドベントカレンダー にWebGLエフェクトのライブラリ「react-vfx」つっこんでみた
の3日目です。 リポジトリはこちら 2日目は @sassy_watson さんでした なんかこのツイート を見て面白そうだなーと思い勢いで参加してみました、参加当時からグラフィック系のライブラリ入れてみようと思っていました p5.jsやthree.jsとかも考えていましたが、なんとなく気になっていたのと入れるだけでなんとなく見た目変わってくれて面白かったのでreact-vfxを入れてみました。 入れてみたはいいけど THREE.WebGLRenderer: Error creating WebGL context. とエラーが出てWebGLが動かない。なんでだろうなあと調べてたら いつのまにか自分でChromeの設定でハードウェアアクセラレーションがオフにしていたようでした 該当プルリクはこちら いきなりちょっと亜流なもの入れちゃいましたが、今後どうなることやら。 なんか知らない人とチーム開発するのは不思議な気持ちですね、 このアドベントカレンダーに参加した結果他の人がどういうもの作るか、どう書くのかがより気になってなぜか毎日見てしまっています。笑 明日4日目は @okumura_daiki さんです、よろしくお願いいたします〜
- 投稿日:2021-12-03T20:45:44+09:00
NextAuth.js+API Routes+Prisma...。Firebase不要!?のフルスタックアプリケーション。
はじめに フロントエンドをメインで学習している僕にとって、Firebaseは欠かせない相棒です。 (いや、"でした"と言うべきでしょうか。今回、「NextAuth.js」と「Next API Routes」にトライするまでは、、) 下記のような技術スタックを用いて簡単なアプリを作成したので、その感想をFirebaseと比較しつつ、書いていきます。 前提として、、 ここでは個人開発等の比較的小規模なアプリケーションを想定して、色々書いております。 技術 Github 実装甘い部分はありますが、、 作成したアプリのURL ゲストログインもあるので、覗いてみて下さい NextAuth.js ~お手軽認証機能~ 「Firebase Authentication」に迫るレベルで簡単に認証機能が実装できてしまいます。 SNSログイン認証であれば、ほぼ遜色ないです。 ただ、メールアドレス認証に関してはFIrebaseには敵わなそうです。 UIをカスタマイズしたり、認証メールを送るのがやや面倒でしょうか。 一方、NextAuthの良い点として、sessinを用いてログインしているかどうかを管理するのが容易であるように感じました。FirebaseはonAuthStateChangedメソッドを用いると思うのですが、このメソッドをどこで呼び出すか、データをどこで保持しておくか悩んだ記憶があります。 メールアドレス認証が不要ならば、NextAuth.jsを推奨したいです。 [...NextAuth.ts]をご確認下さい。 API Routes ~Expressも不要だ!~ create-next-appを実行すると、pagesディレクトリ配下にapiディレクトリがデフォルトで作成されます。このapiディレクトリ配下に作成したファイルで、クライアントから呼び出せるAPI を定義することができます。 Expressを導入する必要も無いのです。 ただ個人的に微妙だったのが、pages配下にapiディレクトリを置くと、フロント側と視覚的に混ざってしまう点です。今回の小規模な開発でさえ、やりにくさを感じました。出来ればAPI側は、フロント側と明確に分別したいところです。 Vercel ~push毎にデプロイ&URL発行~ Githubにプッシュすれば、自動でビルド・デプロイされるので非常にカンタン。 以前、SSRで実装したNuxt.jsのアプリケーションを、「Firebase Hosting」でデプロイしたのですが、「Cloud Functions」の設定が必要で苦労しました。 push毎にデプロイしてくれる点もvercelの良いところです。 ビルドに失敗していたら、プルリクを作成したタイミングで教えてくれます。 テスト ~とりあえずJestを使っておく!~ 今回一番躓いたのが、テストの実装です。 「NextAuth.js」や「API routes」の公式では、Cypressが推奨されております。他にも、next-test-api-route-handlerというモジュールなどが選択肢としてあります。 詳しくは公式ドキュメントでご確認下さい。 githubで色々な実装例は確認したものの、結局Jestを採用しました。 node-mocks-httpを使うと、比較的スッキリ記述出来ました。 ただ、正直APIのテストはかなりサボっております。 ベストプラクティスと言える自信はありませんが、個人的にはJestで落ち着きました。 テストディレクトリをご確認下さい。 まとめ 今後の個人開発は、上記の技術スタックを使用していきたいです。 もちろんFirebaseの方が、開発の全体的なスピードは早いと思います。 しかし、Firebaseに関してのキャッチアップ自体にもそれなりに時間的コストがかかります。 (NoSQLなFirestoreは面白いですが、、) というわけで、しばらくはVue.js(Nuxt.js)からは離れることになりそうです。 Next.jsが素晴らしすぎる、、、
- 投稿日:2021-12-03T19:28:18+09:00
スタイルの適用方法で混乱したのでMUIとChakra UIを例にまとめてみた
これはなに? こんにちは、最近リモートで体重が急増したので焦ってランニングをしている@mu-suke08です。 流行り廃りが激しいフロントエンド業界では、常に新しいUIフレームワークが生まれ、使われなくなるというのが当たり前のように起きています。 筆者はその流れに揉まれて、「結局どの書き方が良いのさ」という思考に陥ったためこれを機にまとめてみようと思います。 今回はMaterial UI(現在のMUI)とChakra UIを使ってみてそれぞれのCSSのあて方についてみていこうと思います。 注意 この記事ではMUIのv4以前をMaterial UI、v5以降をMUIと呼びます。 紹介する内容は以下 - Material UI - MUI - Chakra UI Material UI Material UIのv4以前はMaterial UI, v5以降はMUIとなっています。 知っている人も多いと思いますが、v5では破壊的な変更があり、styling solutionも姿をガラリと変えています。 ここでは変更以前(v4まで)のstyling solutionを3つを紹介します。 Hook API Hook APIはmakeStyleを使った書き方になります。 筆者自身もMaterial UIを使っていた頃によく書いていて、いくつか記事を見ましたがにこれが主流なのかなと思っています。 スネークケースからキャメルケースに変わったくらいで従来のcssと書き方はほぼ一緒です。 クラス名でスタイリングをしている辺りが従来のcssの書き方に似ていますね。 import * as React from 'react'; import { makeStyles } from '@mui/styles'; import Button from '@mui/material/Button'; const useStyles = makeStyles({ root: { background: 'linear-gradient(45deg, #FE6B8B 30%, #FF8E53 90%)', border: 0, borderRadius: 3, boxShadow: '0 3px 5px 2px rgba(255, 105, 135, .3)', color: 'white', height: 48, padding: '0 30px', }, }); export default function Hook() { const classes = useStyles(); return <Button className={classes.root}>Styled with Hook API</Button>; } <Button className={classes.root}>Styled with Hook API</Button> Styled components API Styled components APIは名前の通りstyled-componentsに則って、既存のcomponentをラップして、任意のコンポーネントに再定義するという方法です。 汎用性の低いコンポーネントを書くときは少し面倒だなという印象です。 import * as React from 'react'; import { styled } from '@mui/styles'; import Button from '@mui/material/Button'; const MyButton = styled(Button)({ background: 'linear-gradient(45deg, #FE6B8B 30%, #FF8E53 90%)', border: 0, borderRadius: 3, boxShadow: '0 3px 5px 2px rgba(255, 105, 135, .3)', color: 'white', height: 48, padding: '0 30px', }); export default function StyledComponents() { return <MyButton>Styled with styled-components API</MyButton>; } <MyButton>Styled with styled-components API</MyButton> Higher-order component API これは筆者自身も馴染みがないのですが、 スタイルとUIの2つを用意してexportする際に2つを合体させるというものです。 styled-componentsと若干書き方が似ていますがなんというか、書きづらい印象です。。 import * as React from 'react'; import PropTypes from 'prop-types'; import { withStyles } from '@mui/styles'; import Button from '@mui/material/Button'; const styles = { root: { background: 'linear-gradient(45deg, #FE6B8B 30%, #FF8E53 90%)', border: 0, borderRadius: 3, boxShadow: '0 3px 5px 2px rgba(255, 105, 135, .3)', color: 'white', height: 48, padding: '0 30px', }, }; function UnstyledComponent(props) { const { classes } = props; return <Button className={classes.root}>Styled with HOC API</Button>; } UnstyledComponent.propTypes = { classes: PropTypes.object.isRequired, }; export default withStyles(styles)(UnstyledComponent); MUI ここでは変更後(v5以降)のsolutionについて言及していきます。 MUI's styling solution is inspired by many other styling libraries such as styled-components and emotion. 出典: Why use MUI's styling solution? ここにも書いてあるとおりstyled-componentsとemotionの影響を受けていて、以下のような書き方になりました。 import Button from "@mui/material/Button" import React from "react" const MyButton: React.VFC = () => { return ( <Button sx={{ background: "linear-gradient(45deg, #FE6B8B 30%, #FF8E53 90%)", border: "0", borderRadius: "3px", boxShadow: "0 3px 5px 2px rgba(255, 105, 135, .3)", color: "white", height: "48px", padding: "0 30px", }} > Styled with MUI </Button> ) } export default MyButton Chakra UI Chakra UIは前述したMUIの書き方と似ている印象です。 import { Button } from "@chakra-ui/react" import React from "react" const MyButton: React.VFC = () => { return ( <Button background="linear-gradient(45deg, #FE6B8B 30%, #FF8E53 90%)" border="0" borderRadius="3px" boxShadow="0 3px 5px 2px rgba(255, 105, 135, .3)" color="white" height="48px" padding="0 30px" > Styled with Chakra UI </Button> ) } export default MyButton まとめ Material UI(v4)からChakra UIに乗り換えたときにChakra UIのスタイルのあて方に感動しました。 というのも、CSSを書くというよりかは、Flutterのようなスタイルとそれを適用するコンポーネントが同じ位置にあることがすごい直感的でわかりやすいなと思ったからです。 バージョンアップしたMUIを改めてみてみると、Chakara UIと同じ書き方をしていたので、今のスタイルの適用方法はこれなんだなというのを理解しました。 冒頭でも述べたようにここら辺は流行り廃りが激しいところなので適宜キャッチアップしていきたいです。 ではまた! 参考サイト
- 投稿日:2021-12-03T15:05:20+09:00
React18で正式リリースされるSuspenseについて
はじめに React16.6から実験的機能としてSuspenseコンポーネントが実装されていたが、React18では正式にSuspenseが実装される。この記事ではSuspenseについて解説する。また、例として出すプログラムは全てFunctionコンポーネントを使用した記述とする。 Suspenseとは Suspenseはあるコンポーネントが表示可能になるまでの状態(待機状態)を指定することが可能なコンポーネントである。 よく使われそうな事象としては、Suspenseコンポーネントのpropsであるfallbackに<Loding />(待機画面のコンポーネント)を指定し、その配下に非同期で取得するデータを扱うコンポーネントを配置するようなものである。このようにすることで、データ取得までは<Loading />を呼び出し、取得後はデータを扱うコンポーネントを呼び出すような処理ができる。 非同期で取得するデータを扱うコンポーネントとして<MyPage />を考える。<MyPage />を呼び出し、非同期のデータを取得するまで代わりに<Loading />を呼び出すようなプログラムは以下のように書くことができる。 <Suspense fallback={<Loading />}> <MyPage /> </Suspense> 従来法との比較 このような表示方法はSuspenseを用いるまでもなく、以前から使われてきた。そこで従来の表示方法を紹介する。また、Suspenseを用いた実装と比較をおこないメリットを紹介する。 例としてユーザー名と投稿が見れる掲示板アプリのようなページを考える。従来の実装法では以下のようなプログラムとなる。 function MyPage(): JSX.Element { const [user, setUser] = React.useState<User | null>(null); useEffect(() => { fetchUser().then(u => setUser(u)); }, []); if (user === null) { return <Loading kind="user" /> } return ( <> <h1>{user.name}</h1> <Post /> </> ); } function Post(): JSX.Element { const [posts, setPosts] = useState<Post[] | null>(null); useEffect(() => { fetchPosts().then({data} => setPosts(data.data)); }, []); if (family === null) { return <Loading kind="post" /> } return ( <ul> {posts.map(post => ( <li key={post.id}>{post.text}</li> ))} </ul> ); } データ取得はfetchXXX()のように適当な関数で行えるとした。以上のプログラムは以下のようなフローで動作する。 ユーザーデータの取得開始 <Loading kind="user" />の呼び出し ユーザーデータの取得開始 <Post />の呼び出し 投稿一覧データの取得開始 <Loading kind="user" />の呼び出し 投稿一覧データの取得完了 完全な画面の表示 この場合ユーザーデータの取得が完了してから投稿一覧データを取得することになるので時間の無駄が生じる。これはPromiss.all()のような機能で一度に取得することで解消可能である。プログラムを以下のよう書き換える。 function MyPage(): JSX.Element { const [user, setUser] = React.useState<User | null>(null); const [posts, setPosts] = useState<Post[] | null>(null); useEffect(() => { fetchAll().then(data => { setUser(data.user)); setPosts(data.posts) } }, []); if (user === null) { return <Loading kind="user" /> } return ( <> <h1>{user.name}</h1> <Post posts={posts} /> </> ); } function Post({ posts }: {posts: Post[] | null}): JSX.Element { if (family === null) { return <Loading kind="post" /> } return ( <ul> {posts.map(post => ( <li key={post.id}>{post.text}</li> ))} </ul> ); } このプログラムは以下のフローで動作する。 ユーザーデータの取得開始 投稿一覧データの取得開始 <Loading kind="user" />の呼び出し ユーザーデータの取得完了 投稿一覧データの取得完了 完全な画面の表示 このようにデータの取得を待ってから別データを取得するという無駄は削減できたが、全てのデータを取得するまで全く表示されないという欠点がある。 Suspenceではこの両方の問題を解決することができる。<Suspence />を用いたプログラムは以下のようになる。 function BBS(): JSX.Element { return ( <Suspence fallback={<Loading kind="user" />}> <MyPage /> <Suspence fallback={<Loading kind="post" />}> <Post /> </Suspence> </Suspence> ) } function MyPage(): JSX.Element { const [user, setUser] = React.useState<User | null>(null); useEffect(() => { fetchUser().then(u => setUser(u)); }, []); return ( <h1>{user.name}</h1> ); } function Post(): JSX.Element { const [posts, setPosts] = useState<Post[] | null>(null); useEffect(() => { fetchPosts().then({data} => setPosts(data.data)); }, []); return ( <ul> {posts.map(post => ( <li key={post.id}>{post.text}</li> ))} </ul> ); } 投稿一覧データよりユーザーデータを取得する方が早いとき場合と投稿一覧データを取得する方が早い場合で分けて考える。 ユーザーデータを取得する方が早い場合は以下のようなフローで動作する。 <MyPage />と<Post />の呼び出し <MyPage />と<Post />の両者データ取得中により待機状態 両者待機状態により上位にある<Loading kind="user" />の呼び出し ユーザーデータの取得完了 <Post />のみが待機状態により<Loading kind="post" />の呼び出し 投稿一覧データの取得完了 完全な画面の表示 また、投稿一覧データを取得する方が早いときは以下のようなフローで動作する。 <MyPage />と<Post />の呼び出し <MyPage />と<Post />の両者とデータ取得により待機状態 両者待機状態なので上位にある<Loading kind="user" />の呼び出し 投稿一覧データの取得完了 <Post />の待機状態が解除、<MyPage />は待機状態なので変わらず ユーザーデータの取得完了 完全な画面の表示 後者の場合はPormiss.all()を使用した時と同様のフローだが、前者の場合は投稿一覧データを取得する前にユーザーデータが表示できる利点があることがわかる。また、データの取得を同時に行なっているので、表示速度も先に紹介した手法と比べて同等以上の速さである。 つまり、Suspenceを使うことでデータの取得を行いながら待機状態が解除されたコンポーネントから表示ができ、かつデータの取得後にさらに別データを取得するような怠惰な処理を行わなくて済むというメリットがある。さらに、<Suspence>の配下にコンポーネントを置くだけで達成できるので実装の容易さ、コードの統一などの面から見ても便利なものである。 SSRの強化 Suspenceが追加されたことによりReactのSSR(Sever Side Render)がより便利になった。 従来のReactにおけるSSRは以下のようなフローで行う。 サーバー上で全データ取得 サーバー上でHTMLをレンダリング クライアントでjs読み込み クライアントでjsのロジックをHTMLに接続 上記のフローは一つのステップが全て終わるまで次のステップに移れない。これは全てのデータを取得するまでHTMLのレンダリングができず、画面の表示が遅くなる、全てのjsが読み込まれるまでjsのロジックをHTMLに接続できず、画面は表示されているのにイベントが動作しない(jsロジックの)などのデメリットがある。React18ではSuspenceを利用することでストリーミングHTMLと選択的ハイドレーションの二つが大きな特徴が追加された。これによって前述のデメリットは解決される。 ストリーミングHTML ストリーミングHTMLとはデータの取得前にはfallbackに指定したコンポーネントをその箇所のHTMLとして生成し、データの取得完了後に表示したいHTMLに置き換えるようなスクリプトが書かれた追加のHTMLを送る形式のことである。 例として次のようなプログラムを考える。 function PostDetail({ post }: (post: Post}): JSX.Element { return ( <> <Post post={post} /> <Suspence fallback={<Loading />} /> <Comments /> </Suspence> </> ) } これはある投稿の詳細画面を想定して作成した。<Comments />を読み込む際には投稿に対するコメントを取得する必要があるので<Suspence>で囲い取得完了まで<Loading />を代わりに読み込ませるようにした。データを取得する前にSSRした場合以下のようなHTMLが生成される。 <!--Post--> <article> <p>XX歳になりました!</p> </article> <section id="comments-loading"> <!--Loading--> <img src="loading.gif" alt="Loading" /> </section> <article>に囲まれた部分は<Post>から生成された部分なので問題なく生成される。<section>に囲まれた部分はfallbackに指定した<Loading>が内部に生成される。<section>はHTMLを置き換える部分の目印として生成され、idが割り振られている。実際にデータの取得完了後はサーバーからこのようなHTMLが送信される。 <div hidden id="comments"> <!-- Comments --> <p>おめでとうございます。</p> <p>おめでとうございます。</p> </div> <script> // 簡略化された実装 document.getElementById('sections-spinner').replaceChildren( document.getElementById('comments') ); </script> 実装は簡略化されているが、HTMLを受け取ることで<Loading />の部分が<Comments />に置き換えられる処理が走ることがわかる。このような方式によってデータの取得まで表示できないという問題を解決している。 選択的ハイドレーション ハイドレーションとはjsのロジックをHTMLに付与することを指し、従来はページに必要な全てのjsが読み込まれてなければ行うことができなかった。選択的ハイドレーションでは<Suspence>で囲んだ部分以外のHTMLで必要なjsの読み込みをまず行い付与する、その後<Suspence>に囲まれた部分のHTMLは読み込み後が完了したものから追加でjsのロジックを付与する。これによってあるjsの読み込みが遅いHTMLがあったとして、そのHTMLに対するjsが読み込まれるまで他の部分のイベントが動作しないと言ったUXの悪い現象を防ぐことができる。また、React18ではjsの読み込みがまだ行われていない部分に対してクリックなどのユーザーからの動作があった場合優先して読み込みを行うことができる。 終わりに Suspenceの紹介から、Suspenceに置き換えることのメリット、ReactにおけるSSRに与える影響などを紹介した。とても直感的で便利に使えるコンポーネントなので、React18にアップデートした際は使用したい。 参考
- 投稿日:2021-12-03T14:24:01+09:00
form内でbuttonのtype属性を書いていなかったら、挙動ではまった話
この記事はビビッドガーデン Advent Calendar 2021の7日目です。 こんにちは、食べチョクを開発している遠藤です。 今回はbuttonのtype属性を書いていないおかげで、謎の挙動にハマりました。 発生していた事象 何が起きていたかを話します。 弊社では、Reactを利用して管理画面を作成しています。 サンプルコードは、以下の通りです。 const handleOnChange = (e) => { ... } const handleOnClick = (e) => { alert("Enter押すとこいつが反応するんだよな") } ... ... <form> <input type="text" ... onChange={handleOnChange} /> ... ... ... <button ... onClick={handleOnClick}> 追加する </button> </form> text型でEnterキーを押すとなぜか、handleOnClick呼ばれてしまいます? 正直全然見当違いの場所でイベントが発火しているために、e.preventDefault()で対応する方針で行っていましたが、どうもうまくいかなかったです。 調べていくと、本家のReactでもIssueが報告がされていました。 原因 さて、原因です。 buttonのtype属性を書いていないために起こる挙動が原因です。 ここからはリファレンスを参照します。 type このボタンの既定の動作です。以下の値が指定可能です。 submit: このボタンはフォームのデータをサーバーへ送信します。これはこの属性が に関連付けられたボタンに指定されていない場合、またはこの属性が空であったり不正な値であったりした場合の既定値です。 reset: このボタンはすべてのコントロールを初期値に初期化します。 と同様です。 (この動作はユーザーを困らせる傾向があります。) button: ボタンには既定の動作がなく、既定では押されても何も行いません。この要素のイベントを待ち受けし、イベントが発生すると起動されるクライアント側スクリプトを設定することができます。 そう、この挙動です。 submit: このボタンはフォームのデータをサーバーへ送信します。これはこの属性が に関連付けられたボタンに指定されていない場合、またはこの属性が空であったり不正な値であったりした場合の既定値です。 つまり、form内の場合は、Enterを押した場合の送信ボタンになります。 ボタンがサーバーにデータを送信するためのものでない場合は、 type 属性を button に設定することを忘れないでください。さもないと、フォームデータを送信して (存在しない) レスポンスを読み込み、文書の現在の状態を破棄してしまうおそれがあります。 注意文があるぐらい、罠な挙動になっています。 対策 弊社では同じ過ちを繰り返さないためにも、対策を行います。 今回はeslintでbuttonのtype属性がない場合は、指摘するルールがあったので、それを適用しました。 これでこの話は終わりです。 最後に 弊社では一度問題が発生したら、対策していきます。 問題が発生したら、解決策を練ることが好きな人のための求人があります。 よろしくお願いします!!!
- 投稿日:2021-12-03T14:23:36+09:00
AirtableのデータをGatsbyJSへ引っ張りGraphQLスキーマとして処理できるようにする
Airtableを簡単にGraphQLで扱えたらなーと考えていたのですがGatsbyJSを使えば簡単にできました。 gatsby-source-airtable を使う gatsby-source-airtable という便利なプラグインがあるのでそれをインスト。 yarn add -D gatsby-source-airtable gatsby-source-filesystem gatsby-source-filesystem も入っていないと怒られるので入れておきます。 プラグイン詳細:https://www.gatsbyjs.com/plugins/gatsby-source-airtable/?=airtable 成果物 まずは成果物から。下は2つのシート(テーブル)を引っ張るオプション指定。 gatsby-config.js plugins: [ // 省略 { resolve: `gatsby-source-airtable`, options: { apiKey: process.env.AIRTABLE_API_KEY, concurrency: 5, tables: [ { baseId: process.env.AIRTABLE_SHEET_ID, // テーブル専用ドキュメントページのURLの一部がID名となっている tableName: `TestData`, // 対象のシート名 queryName: `TestGraph`, // 複数のシートを引っ張るかつキーが被るなら欲しい separateNodeType: true, }, { baseId: process.env.AIRTABLE_SHEET_ID, tableName: `HogeData`, queryName: `HogeGraph`, separateNodeType: true, } ] } } // 省略 ] 上のようにプラグインのオプションを設定してあげるとこのようにAirtableのスキーマが生成されています。 最小構成 gatsby-config.js plugins: [ // 省略 { resolve: `gatsby-source-airtable`, options: { apiKey: process.env.AIRTABLE_API_KEY, concurrency: 5, tables: [ { baseId: process.env.AIRTABLE_SHEET_ID, // テーブル専用ドキュメントページのURLの一部がID名となっている tableName: `TestData`, // 対象のシート名 } ] } } // 省略 ] これだけでも引っ張ってこれます。 allAirtable, airtable が GraphiQL上で生成されているので確認できるかと。できていない場合は設定ミスなどが考えられます。 { baseId: process.env.AIRTABLE_SHEET_ID, tableName: `TestData`, queryName: `TestGraph`, // +追加 separateNodeType: true, // +追加 }, ここでオプションを追加してみました。 queryNameはGraphQL上に edges/node/queryName というディレクトリで設定した名前を引っ張ってこれます。複数テーブルが混在する場合にqueryName別に引っ張ってこれるオプション。 separateNodeTypeは "airtableAll任意の名前(queryNameで指定した文字)" でスキーマをそれぞれ生成してくれます。 それぞれのテーブル名の間でデータを完全に分けて扱う場合は一緒に使うと便利です。
- 投稿日:2021-12-03T14:13:49+09:00
【typescript, React】react-hook-formでフォームを作るために必要最低限なこと
概要 react-hook-formを用いた、フォームの作り方を説明します。 validationなどの設定は置いておいて、本当に必要最低限のフォームを作るために必要なことをメモします。 言語はtypescriptを使用しています。 手順 react-hook-formのインストールとimport 取得したいデータの型を定義する registerとhandleSubmitをuseFormを用いて定義する。 handleOnSubmitを定義する。(関数名は他の名前でOK) HTMLの記述 順を追って説明していきます。 具体的な実装 1.react-hook-formのインストールとimport ターミナルを立ち上げて以下のコマンドを入力してください。 terminal $ yarn add react-hook-form その後react-hook-formを使いたいファイル内でimport文を書きます。 import { SubmitHandler, useForm } from 'react-hook-form' ここでimportしたものは後で使用します。重要なのはuseFormです。 2. 取得したいデータの型を定義する (例) type ValuesType = { name: string age: number phone: number } 上記のようにして入力してもらいたデータの型を定義しましょう。 フォームの値につける変数名:そのデータの型 のようにして定義していきます。 3. registerとhandleSubmitをuseFormを用いて定義する。 (例) const { register, handleSubmit } = useForm<ValuesType>({ mode: "onSubmit", reValidateMode: "onChange", }); このようにしてregisterとhandleSubmitを定義します。これらの変数の名前は変更しないようにしましょう。 (もちろんmodeやreValidateModeには様々な変数を割り当てることができますし、それ以外にも多くのオプションを利用可能です。詳しくは公式のドキュメントが詳しいと思います。) 4. handleOnSubmitを定義します。 (例) const handleOnSubmit: SubmitHandler<ValuesType> = (data) => { console.log(data) } 最初にimportしたSubmitHandlerを型宣言に用います。 今回はコンソールに取得したデータを表示させているだけです。 (apiにそのデータを渡したいときは、ここでaxiosなどを用いてPostなどを行うことができます。) 5. HTMLの記述 (例) return ( <form onSubmit={handleSubmit(handleOnSubmit)} > <input type="text" {...register('name')} /> <input type="number" {...register('age')} /> <input type="number" {...register('phone')} /> <input type="submit" /> </form> ) このようにしてフォームを作成します。inputタグの中の{...register('name')}という部分が肝で、これによって入力されたデータがどのデータに対応しているのかを記述しています。 まとめ 今回は必要最低限のreact-hook-formの使い方を説明しました。 個人的にはここまでくるのが、一番大変だったので記事にしました。 あとは色々なオプションについて公式ドキュメントなどを参照しながら、勉強してみてください!
- 投稿日:2021-12-03T14:05:12+09:00
失敗から学ぶOSSの選び方(フロントエンド編)
2021/01 に迎えた FlashEOL のタイミング で Flash から Reactベースの画面へリプレースし、約1年間保守しました。 OSS にたくさん助けてもらいましたが、1年間を振り返ってみて今後の教訓を考えたいと思います。 前提 React + React Hook Form + TypeScript + material-ui + webpack という構成です。 初回のOSS選定は以下の観点で行いました。 学習コストが高くないか 日本語の公式ドキュメントがあるか ググったときに日本語の解説記事が豊富か コミュニティが活発か GitHub の Star数が 1000 以上 (目安) npm の ダウンロード数が 50000 以上 (目安) 1. 自動テストがだんだん遅くなってきた? 背景 自動テストをするため、Cypress を導入していました。 Cypress はテスト失敗箇所が画面上で確認できるため、初心のうちは見やすく重宝していました。 失敗 Cypress はデフォルトで直列実行されるため、テストケースの増加に対してテスト時間の悪化が顕著となりました。 そこで並列実行を考えましたが、なんと Cypress.io (CIサービス) 以外で並列実行が出来ない仕様でした。(そういうマネタイズなんですかね...) 対して jest はどこでも並列実行が可能なので移行しました。testing-library や enzyme など選択肢が多く敬遠していましたが、早く使っておけばよかった 教訓 テストライブラリを選ぶときは、テストケースが多くなっても開発効率に影響が出ないか確認/検証しよう。 2. OSSがメンテナンスされなくなった? 背景 material-table は、material-ui をベースとしたテーブルコンポーネントです。 material-ui の公式ドキュメントで紹介されるほど人気があります。 (https://github.com/mui-org/material-ui/commit/e470b1ba9c234f16972749aa7c88d331ac9f46c1) カスタマイズ性の非常に高いコンポーネントなので、開発しているプロジェクトの主軸となるライブラリとなっています。 グルーピング機能にはかなり救われました。 失敗 2020年夏ごろ、多くの不具合を残したままバージョン更新が止まってしまいました。 material-ui の公式ドキュメントからもいつの間にか紹介文が消されてしまいました? material-table は React V16 に依存しているため、React V17 に上げることが出来なくなってしまいかなり頭を悩ませるライブラリとなっています... どうやらメンテナである mbrn 氏がメンテナンスできる状態ではなくなってしまい、他にメンテナもいないため保守できなくなったらしいです。 ( https://github.com/mbrn/material-table/issues/3044 ) material-table-core というコミュニティによるフォークプロジェクトが立ち上がっていますが、まだまだStar数/ダウンロード数が少ないため見送っています。流行ってー 教訓 メンテナが1人しかいないOSSはある日突然更新が止まることがあるので、見送ろう。 結び 安定して継続的に使っていけるライブラリを選んだはずですが、1年経つと状況が変わってしまいますね。 フロントエンドのライブラリは栄枯盛衰が激しいですが、長く使っていけるかどうかをよく判断して選定していきたいです。
- 投稿日:2021-12-03T13:01:03+09:00
Remixやってみた
はじめに はじめまして! 兼業で個人サービスの開発・運営・保守を行っております。 フロントエンジニアのふぁると申します。 統合テストのクラウド管理・実行プラットフォーム「Itamaster」を運営しております。 よろしくお願いいたします。 【Twitterリンク】 https://twitter.com/@itamaster_ 【Itamaster公開記事リンク】 https://qiita.com/Itamaster/items/f821be4c33caab640a93 【Itamaster】 https://itamaster.work テスト管理プラットフォーム Itamaster 注)当記事では、公式のリファレンス等を参照しながら記述を行っていますが、私自身未熟な身であるため、見解等に間違いがあった場合は指摘、コメント等どうかよろしくお願いいたします。 Remixってなに Remixとは、2021/11/23にOSSとしてリリースされたばかりの、Reactをベースにした新しいフルスタックフレームワークです。 Reactベースのフレームワークというと、Next.jsがスタンダードですが、RemixはNext.jsやGatsbyとは一線を画す設計思想となっています。 具体的には、SSG(静的HTMLを予めジェネレートし、ブラウザにはHTMLを配信する形式)を撤廃し、SSR(サーバーにレンダリングの責務を持たせる方式)、ブラウザのFetchAPI等のエコシステムを利用し処理・描画速度の向上を図っています。 環境の構築 node.jsが既インストール済であることが前提となります。 $ npx create-remix@latest 色々聞かれるので答えていきます。 デプロイ先をNetlifyとか、VercelとかRemix app serverとかから選べるそうです。 折角なら収益化を行いたかったので、使い慣れたNetlifyを選択しました。 (利用規約では、Vercelは無料枠では商用利用が出来ません。) .envやprisma等、色々追加はしていますが大まかにはこんな感じにディレクトリが作られます。 デプロイ先にNetlifyを指定したため、netlifyの設定ファイルが生成されたり、netlify用のディレクトリが作られたりしてます。 $ npm run dev すると、localhost:3000にチュートリアルページが表示されます。 ※ 展開先:netlify限定かどうかわかりませんが、私の環境ではpackage.json内のdevスクリプトをwatchからdevに切り替えたり、@remix-run/serveといったライブラリのインストールが必要でした。 コードを読んでいく entry.client.tsx entry.client.tsx import { hydrate } from "react-dom"; import { RemixBrowser } from "remix"; hydrate(<RemixBrowser />, document); Reactコンポーネントをdocumentにマウントし描画するためのものだと思われ......? hydrateはreact-domの関数なので、公式リファレンスを読んでみます。 render() と同様ですが、ReactDOMServer により HTML コンテンツが描画されたコンテナをクライアントで再利用するために使用されます。React は既存のマークアップにイベントリスナをアタッチしようとします。 React はレンダーされる内容が、サーバ・クライアント間で同一であることを期待します。React はテキストコンテンツの差異を修復することは可能ですが、その不一致はバグとして扱い、修正すべきです。開発用モードでは、React は両者のレンダーの不一致について警告します。不一致がある場合に属性の差異が修復されるという保証はありません。これはパフォーマンス上の理由から重要です。なぜなら、ほとんどのアプリケーションにおいて不一致が発生するということは稀であり、全てのマークアップを検証することは許容不可能なほど高コストになるためです。 SSRの仕組みによってコンポーネントがマウントされたコンテナをwindow.documentに描画するためのものでしょうか。 entry.server.tsx entry.server.tsx import { renderToString } from "react-dom/server"; import { RemixServer } from "remix"; import type { EntryContext } from "remix"; export default function handleRequest( request: Request, responseStatusCode: number, responseHeaders: Headers, remixContext: EntryContext ) { let markup = renderToString( <RemixServer context={remixContext} url={request.url} /> ); responseHeaders.set("Content-Type", "text/html"); return new Response("<!DOCTYPE html>" + markup, { status: responseStatusCode, headers: responseHeaders }); } Request->Response時の共通処理っぽいのが書いてあるように見えます。 renderToString関数について、公式リファレンスを読んでみます。 コンポーネントを静的なマークアップとして変換できるようにします。 サーバとブラウザの両方の環境で使用できる関数のようで、JSX式で書かれたReactコンポーネントをHTMLに描画したり、初期リクエストに対してはマークアップに変換しブラウザに読み込ませることで読み込み速度を向上させSEO対策を可能にしたり、しているそうです。 SSRもSSGもイマイチ詳しくないですが、リファレンスを読む限りSSRのエコシステムを中核的な機能のように見えます。 RemixServer関数は、contextとurlを受け取り、ReactElement型を返す関数であるようです。 renderToString関数にcomponentとして渡されているあたり、urlやcontextからroutes内のコンポーネントをマッピングし返す役割でも持っているのでしょうか? returnあたりはレスポンスを整形しているっぽく見えます。 root.tsx root.tsx(長いので注意) root.tsx import { Link, Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration, useCatch } from "remix"; import type { LinksFunction } from "remix"; import globalStylesUrl from "~/styles/global.css"; import darkStylesUrl from "~/styles/dark.css"; // https://remix.run/api/app#links export let links: LinksFunction = () => { return [ { rel: "stylesheet", href: globalStylesUrl }, { rel: "stylesheet", href: darkStylesUrl, media: "(prefers-color-scheme: dark)" } ]; }; // https://remix.run/api/conventions#default-export // https://remix.run/api/conventions#route-filenames export default function App() { return ( <Document> <Layout> <Outlet /> </Layout> </Document> ); } // https://remix.run/docs/en/v1/api/conventions#errorboundary export function ErrorBoundary({ error }: { error: Error }) { console.error(error); return ( <Document title="Error!"> <Layout> <div> <h1>There was an error</h1> <p>{error.message}</p> <hr /> <p> Hey, developer, you should replace this with what you want your users to see. </p> </div> </Layout> </Document> ); } // https://remix.run/docs/en/v1/api/conventions#catchboundary export function CatchBoundary() { let caught = useCatch(); let message; switch (caught.status) { case 401: message = ( <p> Oops! Looks like you tried to visit a page that you do not have access to. </p> ); break; case 404: message = ( <p>Oops! Looks like you tried to visit a page that does not exist.</p> ); break; default: throw new Error(caught.data || caught.statusText); } return ( <Document title={`${caught.status} ${caught.statusText}`}> <Layout> <h1> {caught.status}: {caught.statusText} </h1> {message} </Layout> </Document> ); } function Document({ children, title }: { children: React.ReactNode; title?: string; }) { return ( <html lang="en"> <head> <meta charSet="utf-8" /> <meta name="viewport" content="width=device-width,initial-scale=1" /> {title ? <title>{title}</title> : null} <Meta /> <Links /> </head> <body> {children} <ScrollRestoration /> <Scripts /> {process.env.NODE_ENV === "development" && <LiveReload />} </body> </html> ); } function Layout({ children }: { children: React.ReactNode }) { return ( <div className="remix-app"> <header className="remix-app__header"> <div className="container remix-app__header-content"> <Link to="/" title="Remix" className="remix-app__header-home-link"> <RemixLogo /> </Link> <nav aria-label="Main navigation" className="remix-app__header-nav"> <ul> <li> <Link to="/">Home</Link> </li> <li> <a href="https://remix.run/docs">Remix Docs</a> </li> <li> <a href="https://github.com/remix-run/remix">GitHub</a> </li> </ul> </nav> </div> </header> <div className="remix-app__main"> <div className="container remix-app__main-content">{children}</div> </div> <footer className="remix-app__footer"> <div className="container remix-app__footer-content"> <p>© You!</p> </div> </footer> </div> ); } function RemixLogo() { return ( <svg viewBox="0 0 659 165" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" aria-labelledby="remix-run-logo-title" role="img" width="106" height="30" fill="currentColor" > <title id="remix-run-logo-title">Remix Logo</title> <path d="M0 161V136H45.5416C53.1486 136 54.8003 141.638 54.8003 145V161H0Z M133.85 124.16C135.3 142.762 135.3 151.482 135.3 161H92.2283C92.2283 158.927 92.2653 157.03 92.3028 155.107C92.4195 149.128 92.5411 142.894 91.5717 130.304C90.2905 111.872 82.3473 107.776 67.7419 107.776H54.8021H0V74.24H69.7918C88.2407 74.24 97.4651 68.632 97.4651 53.784C97.4651 40.728 88.2407 32.816 69.7918 32.816H0V0H77.4788C119.245 0 140 19.712 140 51.2C140 74.752 125.395 90.112 105.665 92.672C122.32 96 132.057 105.472 133.85 124.16Z" /> <path d="M229.43 120.576C225.59 129.536 218.422 133.376 207.158 133.376C194.614 133.376 184.374 126.72 183.35 112.64H263.478V101.12C263.478 70.1437 243.254 44.0317 205.11 44.0317C169.526 44.0317 142.902 69.8877 142.902 105.984C142.902 142.336 169.014 164.352 205.622 164.352C235.83 164.352 256.822 149.76 262.71 123.648L229.43 120.576ZM183.862 92.6717C185.398 81.9197 191.286 73.7277 204.598 73.7277C216.886 73.7277 223.542 82.4317 224.054 92.6717H183.862Z" /> <path d="M385.256 66.5597C380.392 53.2477 369.896 44.0317 349.672 44.0317C332.52 44.0317 320.232 51.7117 314.088 64.2557V47.1037H272.616V161.28H314.088V105.216C314.088 88.0638 318.952 76.7997 332.52 76.7997C345.064 76.7997 348.136 84.9917 348.136 100.608V161.28H389.608V105.216C389.608 88.0638 394.216 76.7997 408.04 76.7997C420.584 76.7997 423.4 84.9917 423.4 100.608V161.28H464.872V89.5997C464.872 65.7917 455.656 44.0317 424.168 44.0317C404.968 44.0317 391.4 53.7597 385.256 66.5597Z" /> <path d="M478.436 47.104V161.28H519.908V47.104H478.436ZM478.18 36.352H520.164V0H478.18V36.352Z" /> <path d="M654.54 47.1035H611.788L592.332 74.2395L573.388 47.1035H527.564L568.78 103.168L523.98 161.28H566.732L589.516 130.304L612.3 161.28H658.124L613.068 101.376L654.54 47.1035Z" /> </svg> ); } 「うぇ、Remix」って感じがしますが、落ち着いて読んでいこうと思います。 export let links: LinksFunction = () => { return [ { rel: "stylesheet", href: globalStylesUrl }, { rel: "stylesheet", href: darkStylesUrl, media: "(prefers-color-scheme: dark)" } ]; }; まず、最上部のlinksとやらをexportしている部分について。 letで宣言されていますね。 Remixの公式リファレンスを何日か前に読んでいた時は、「letは三文字で楽だからconstじゃなくてlet使ってるぜ!まぁconst使ってくれてもいいけどな!」みたいな陽気な事が書いてあり印象によく残っているのですが、記事執筆時に原文を探しに行くと見つかりませんでした。 もしかして消された? まず、ぱっと見で見て取れるのは、linksは外部のcssを読み込んでいるようであるという事です。 prefers-color-schemeというのは、ユーザーがシステムに要求したカラーテーマが明色か暗色かを検出するためのもののようです。 グローバルにスタイルを読み込ませるためにroot.tsxにLinksFunctionという型を使っているあたり、root.tsxはReactやVueでいうところのdefaultLayout的なものではないでしょうか。 export default function App() { return ( <Document> <Layout> <Outlet /> </Layout> </Document> ); } Appの既定エクスポートです。 Document、Layoutは以下に出てくるようですが、OutletはReact Router v6の新機能ですね。 子インデックスコンポーネントをレンダリングするものです。 例えば、app/hoge.tsxのようなパスに存在するコンポーネントでOutletを呼び出した場合、app/hoge/index.tsxの既定エクスポートが呼び出され、描画されます。 子インデックスコンポーネントが存在しない場合はレンダリングは行われません。 今回だと、デフォルトではroutes/index.tsxが呼び出されることになります。 export function ErrorBoundary({ error }: { error: Error }) { console.error(error); return ( <Document title="Error!"> <Layout> <div> <h1>There was an error</h1> <p>{error.message}</p> <hr /> <p> Hey, developer, you should replace this with what you want your users to see. </p> </div> </Layout> </Document> ); } ErrorBoundaryコンポーネントは、サーバー、フロント両方のエラーをキャッチし、既定エクスポートのコンポーネントに対しマウントを行うことが出来ます。 公式リファレンスを参照します。 Remix sets a new precedent in web application error handling that you are going to love. Remix automatically catches most errors in your code, on the server or in the browser, and renders the closest ErrorBoundary to where the error occurred. If you're familiar with React's componentDidCatch and getDerivedStateFromError class component hooks, it's just like that but with some extra handling for errors on the server. ページ全体を読んで、和訳すると以下のような内容が記述されているようです。 Remixは全てのエラーをキャッチし、ErrorBoundaryをレンダリングします。ErrorBoundaryが複数コンポーネントに配置されている場合、最も近いErrorBoundaryがマウントされます。 ブラウザでのレンダリング、サーバーでのレンダリング、サーバー処理、クライアントサイドでの処理中の全てのエラーの全てをキャッチすることが可能です。 rootにおいてエラーが起きた場合、画面全体(root以下のOutlet全体)がErrorBoundaryがレンダリングされ、 子孫コンポーネント内でエラーが起きた場合は子孫コンポーネント内のErrorBoundaryのみが子孫コンポーネントにマウントされます。(子孫コンポーネントにErrorBoundaryが宣言されていない場合は、rootのErrorBoundaryが適用されます。) 子孫コンポーネント内のErrorBoundaryがマウントされた場合、それ以外の箇所は正常にレンダリングされているのが特徴です。 export function CatchBoundary() { let caught = useCatch(); let message; switch (caught.status) { case 401: message = ( <p> Oops! Looks like you tried to visit a page that you do not have access to. </p> ); break; case 404: message = ( <p>Oops! Looks like you tried to visit a page that does not exist.</p> ); break; default: throw new Error(caught.data || caught.statusText); } return ( <Document title={`${caught.status} ${caught.statusText}`}> <Layout> <h1> {caught.status}: {caught.statusText} </h1> {message} </Layout> </Document> ); } CatchBoundaryは、loaderやルーティングによってキャッチした応答結果を元に、をキャッチしマウントを行うようです。 基本的にはErrorBoundaryと同様です。 主観ですが、これらは本当にとても強力に見えます。コンポジションAPIの究極形みたいな印象を受けます。 function Document({ children, title }: { children: React.ReactNode; title?: string; }) { return ( <html lang="en"> <head> <meta charSet="utf-8" /> <meta name="viewport" content="width=device-width,initial-scale=1" /> {title ? <title>{title}</title> : null} <Meta /> <Links /> </head> <body> {children} <ScrollRestoration /> <Scripts /> {process.env.NODE_ENV === "development" && <LiveReload />} </body> </html> ); } 既定エクスポート等で使用されているDocumentコンポーネントの宣言箇所です。 Childrenとして子コンポーネントを受け取ったり、titleタグを出力しています。 ScrollRestorationはReact Routerのスクロール制御を踏襲したものかと思われます。 公式リファレンスによると、画面遷移時のブラウザのスクロール位置を復元するものだそうです。(ページ遷移を行い、該当の画面に戻ってきたとき、最後に該当のページに留まっていた際のスクロール位置を復元するということだと存じます。) Scriptsタグの直前に配置するようです。 開発環境時のみ、LiveReloadコンポーネントレンダリングします。 LiveReloadコンポーネントを配置しておくことで、開発中に変更を加えた場合ブラウザを自動更新します。 function Layout({ children }: { children: React.ReactNode }) { return ( <div className="remix-app"> <header className="remix-app__header"> <div className="container remix-app__header-content"> <Link to="/" title="Remix" className="remix-app__header-home-link"> <RemixLogo /> </Link> <nav aria-label="Main navigation" className="remix-app__header-nav"> <ul> <li> <Link to="/">Home</Link> </li> <li> <a href="https://remix.run/docs">Remix Docs</a> </li> <li> <a href="https://github.com/remix-run/remix">GitHub</a> </li> </ul> </nav> </div> </header> <div className="remix-app__main"> <div className="container remix-app__main-content">{children}</div> </div> <footer className="remix-app__footer"> <div className="container remix-app__footer-content"> <p>© You!</p> </div> </footer> </div> ); } Layoutコンポーネントは、この中で一番defaultLayoutっぽい箇所です。 システム全体の共通のレイアウトがまとまっているように見えます。 Documentコンポーネント、あるいは、ErrorBoundary内でのエラー時処理、あるいは子インデックスルートのコンポーネント等のReact.nodeを受け取り、描画を行う際の共通の処理について記述を行っています。 ここでは、ヘッダー、フッターを定義しているようです。 function RemixLogo() { return ( <svg viewBox="0 0 659 165" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" aria-labelledby="remix-run-logo-title" role="img" width="106" height="30" fill="currentColor" > <title id="remix-run-logo-title">Remix Logo</title> <path d="M0 161V136H45.5416C53.1486 136 54.8003 141.638 54.8003 145V161H0Z M133.85 124.16C135.3 142.762 135.3 151.482 135.3 161H92.2283C92.2283 158.927 92.2653 157.03 92.3028 155.107C92.4195 149.128 92.5411 142.894 91.5717 130.304C90.2905 111.872 82.3473 107.776 67.7419 107.776H54.8021H0V74.24H69.7918C88.2407 74.24 97.4651 68.632 97.4651 53.784C97.4651 40.728 88.2407 32.816 69.7918 32.816H0V0H77.4788C119.245 0 140 19.712 140 51.2C140 74.752 125.395 90.112 105.665 92.672C122.32 96 132.057 105.472 133.85 124.16Z" /> <path d="M229.43 120.576C225.59 129.536 218.422 133.376 207.158 133.376C194.614 133.376 184.374 126.72 183.35 112.64H263.478V101.12C263.478 70.1437 243.254 44.0317 205.11 44.0317C169.526 44.0317 142.902 69.8877 142.902 105.984C142.902 142.336 169.014 164.352 205.622 164.352C235.83 164.352 256.822 149.76 262.71 123.648L229.43 120.576ZM183.862 92.6717C185.398 81.9197 191.286 73.7277 204.598 73.7277C216.886 73.7277 223.542 82.4317 224.054 92.6717H183.862Z" /> <path d="M385.256 66.5597C380.392 53.2477 369.896 44.0317 349.672 44.0317C332.52 44.0317 320.232 51.7117 314.088 64.2557V47.1037H272.616V161.28H314.088V105.216C314.088 88.0638 318.952 76.7997 332.52 76.7997C345.064 76.7997 348.136 84.9917 348.136 100.608V161.28H389.608V105.216C389.608 88.0638 394.216 76.7997 408.04 76.7997C420.584 76.7997 423.4 84.9917 423.4 100.608V161.28H464.872V89.5997C464.872 65.7917 455.656 44.0317 424.168 44.0317C404.968 44.0317 391.4 53.7597 385.256 66.5597Z" /> <path d="M478.436 47.104V161.28H519.908V47.104H478.436ZM478.18 36.352H520.164V0H478.18V36.352Z" /> <path d="M654.54 47.1035H611.788L592.332 74.2395L573.388 47.1035H527.564L568.78 103.168L523.98 161.28H566.732L589.516 130.304L612.3 161.28H658.124L613.068 101.376L654.54 47.1035Z" /> </svg> ); } Header内で使用されているRemixのロゴです。 svgってみんなこれわかって書いているんですか? ジェネレータとかあるんでしょうか......? ここまでがエントリーポイントであるrootの解析でした。 routes/index.tsx root.tsxは全てのコードに着目しましたが、routes内は気になるところをピックアップして見ていこうと思います。 routes/index.tsx export let loader: LoaderFunction = () => { let data: IndexData = { resources: [ { name: "Remix Docs", url: "https://remix.run/docs", }, { name: "React Router Docs", url: "https://reactrouter.com/docs", }, { name: "Remix Discord", url: "https://discord.gg/VBePs6d", }, ], demos: [ { to: "demos/actions", name: "Actions", }, { to: "demos/about", name: "Nested Routes, CSS loading/unloading", }, { to: "demos/params", name: "URL Params and Error Boundaries", }, ], }; return json(data); }; LoaderFunction型の関数が出現しました。 パッケージ内のコードを読むと、LoaderFunction型はPromise、Response、AppDataをreturnします。 よって、該当のlet loader Functionは本来バックエンド側の処理を行うような使い方をされるものではないかと推測されます。 前述したentry.server.tsxを共通処理として、エントリーポイントであるindex.tsx内の/のリクエストであることから、 GET / 200(status code)をResponseとして返しているように読めます。 routes/index.tsx export default function Index() { let data = useLoaderData<IndexData>(); return ( <div className="remix__page"> {// この辺に<main>~なんちゃらかんちゃらありました。省略します。} <aside> <h2>Demos In This App</h2> <ul> {data.demos.map((demo) => ( <li key={demo.to} className="remix__page__resource"> <Link to={demo.to} prefetch="intent"> {demo.name} </Link> </li> ))} </ul> <h2>Resources</h2> <ul> {data.resources.map((resource) => ( <li key={resource.url} className="remix__page__resource" > <a href={resource.url}>{resource.name}</a> </li> ))} </ul> </aside> </div> ); } index.tsxの既定エクスポートです。 先ほどのloaderファンクション内で GET / 200 とResponseが書いてある様子を記述しましたが、肝心のreturnの受け取り方がここに書かれています。 useLoaderData関数はルートローダー関数からJSONを受け取るフックです。 この場合は、loaderからreturn json(data)で返されている値を let data にIndexData型として受け取っています。 routes/index.tsx export let meta: MetaFunction = () => { return { title: "Remix Starter", description: "Welcome to remix!", }; }; MetaFunction型を使うと、metaタグをオーバーライド出来るようです。 Prismaを導入し、DBに対して取得・作成処理を行ってみる ORMはPrisma、DBはSQLiteを利用し、サーバー側の処理をコンポーネント内に書いてみたりしてみようと思います。 とりあえずprisma関連のライブラリをインストールします。 $ npm install --save-dev prisma $ npm install @prisma/client チュートリアルに従い、Jokesというテーブルを作るため、以下のファイルを作成します。 prisma/schema.prisma generator client { provider = "prisma-client-js" } datasource db { provider = "sqlite" url = env("DATABASE_URL") } model Joke { id String @id @default(uuid()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt name String content String } prismaのマイグレーションファイルは初めて書きましたが、書き方とか独特だなーって思います。 DATABASE_URLをenvに記述します。 .env DATABASE_URL="file:./dev.db" seedファイルも作成します。 prisma/seed.ts import { PrismaClient } from "@prisma/client"; let db = new PrismaClient(); async function seed() { await Promise.all( getJokes().map(joke => { return db.joke.create({ data: joke }); }) ); } seed(); function getJokes() { return [ { name: "シードで作成したJoke Data", content: `このデータは開発チュートリアル用にseedしています。` }, { name: "シードで作成したJoke Data 02", content: `このデータは開発チュートリアル用にseedしています。02` }, ]; } まあまあ、ふーんって感じです。 色々調べてみましたが、prismaの記述は情報量も多いので、割愛します。 とりあえずマイグレーションしてシーダーを実行してみます。 $ npx prisma init --datasource-provider sqlite $ npx prisma db push $ npm install --save-dev esbuild-register $ node --require esbuild-register prisma/seed.ts 順に、prismaをsqliteでinitし、dbをsqliteに出力、esbuild-registerでseedファイルを実行しています。 DBからの取得 app/routes/demos/にjokes.tsxを作成しました。 jokes.tsx import type { LoaderFunction } from "remix"; import { Outlet } from "react-router"; import type { Joke } from "@prisma/client"; import { useLoaderData, json, Link } from "remix"; import { db } from "~/utils/db.server"; type LoaderData = { jokeListItems: Array<{ id: string; name: string; content: string }>; }; export let loader: LoaderFunction = async () => { let data: LoaderData = { jokeListItems: await db.joke.findMany({ // take: 5, select: { id: true, name: true, content: true }, orderBy: { createdAt: "desc" }, }), }; return data; }; export default function Jokes() { let data = useLoaderData<LoaderData>(); return ( <> <ul> {data.jokeListItems.map((joke) => ( <li> {joke.name}:<p>{joke.content}</p> </li> ))} </ul> <Outlet /> </> ); } 処理を分割して読んでいこうと思います。 type LoaderData = { jokeListItems: Array<{ id: string; name: string; content: string }>; }; 取得し、受け取る情報の型を定義します。 export let loader: LoaderFunction = async () => { let data: LoaderData = { jokeListItems: await db.joke.findMany({ // take: 5, select: { id: true, name: true, content: true }, orderBy: { createdAt: "desc" }, }), }; return data; }; root.tsxにも出てきたloaderFunctionですが、DBからの取得を行う場合はこれでデータが取得できます。 type LoaderDataで定義したjokeListItemsに、db.jokeと記述することでデータのセットが可能です。 findMany関数を利用する場合は、様々な条件を設定できるようです。 take = 取得するデータ数 select = 取得するカラム orderBy = 整列順序 etc... 超簡単で手軽ですね。 エラーハンドリングを行っていない理由としては、エラーが起きた場合にBoudaryが勝手に起動してくれることです。 root.tsxで出てきたErrorBoundary、CatchBoundaryは、サーバーサイドの処理中のエラーもキャッチしてくれます。 また、jokes.tsx限定のErrorBoundary、CatchBoundaryを記述することも可能です。 export default function Jokes() { let data = useLoaderData<LoaderData>(); return ( <> <ul> {data.jokeListItems.map((joke) => ( <li> {joke.name}:<p>{joke.content}</p> </li> ))} </ul> <Outlet /> </> ); } rootの時と同様、useLoaderDataを使って情報を受け取ります。 ⇑で、localhost:xxx/demos/jokesにアクセスした場合にdb.jokeのデータを全表示するところまでが実装出来ました。 作成 localhost:xxx/demos/jokes画面の下らへんに作成用のインプットフィールドを作成しようと思います。 (jokes.tsxのOutletに嵌まるコンポーネントを作成します。) app/routes/demos/jokes/index.tsxを作成しました。 app/routes/demos/jokes/index.tsx import type { ActionFunction } from "remix"; import { redirect } from "remix"; import { db } from "~/utils/db.server"; export let action: ActionFunction = async ({ request }) => { let form = await request.formData(); let name = form.get("name"); let content = form.get("content"); // we do this type check to be extra sure and to make TypeScript happy // we'll explore validation next! if (typeof name !== "string" || typeof content !== "string") { throw new Error(`Form not submitted correctly.`); } let fields = { name, content }; let joke = await db.joke.create({ data: fields }); }; export default function JokesIndex() { return ( <div> <p>Add your own hilarious joke</p> <form method="post"> <div> <label> Name: <input type="text" name="name" /> </label> </div> <div> <label> Content: <textarea name="content" /> </label> </div> <div> <button type="submit" className="button"> Add </button> </div> </form> </div> ); } 読んでいきます。 export let action: ActionFunction = async ({ request }) => { let form = await request.formData(); let name = form.get("name"); let content = form.get("content"); let fields = { name, content }; let joke = await db.joke.create({ data: fields }); }; ActionFunction型は、引数としてDataFunctionArgs型を受け取り、Response、Promise、AppDataをreturnします。 formタグ内からsubmitされaction関数が呼び出された時、既定エクスポート内のformタグ内のデータを受け取ります。 formタグ内のデータをname,contentにセットし、db.joke.createします。 これで、作成が可能です。 総評 リリース直後で情報量が少ないことが大きなデメリットではありますが、remix自体はとても強力なフレームワークだと考えます。 エラー処理をイチイチ書かず、キャッチしたらそのままreactコンポーネントをレンダリングすることが出来る点や、 typescriptでサーバーサイドの記述が可能で、reactがベースである事そのもの等です。 機会があれば、またremixの記事も出していきたいと思っています。 フォロー等、よろしくお願いします。
- 投稿日:2021-12-03T11:29:46+09:00
Reactを基本からまとめてみた【19】【React Router ②】
ルーティング(Routing)とは ルーティング(Routing)とは、ユーザーからの入力に応じて表示させるページを出し分ける処理を指す。 今までのWebアプリケーションはサーバーサイドでルーティングを行うことが主流だったが、Ajax(Asynchronous JavaScript and XML)という非同期的にサーバーとの通信ができる技術が普及したことにより、クライアントサイドでルーティングを行うことが増えきた。いわゆるSPA(Single Page Application)と呼ばれるもの。 SPAはSingle Pageと呼ばれているように、1枚のHTMLファイルをレンダリングしてブラウザに表示している。SPAではページ内でDOMを色々変更してもURLは変わらない。ルーティングによりURLで画面を指定する(DOMの状態を保存する)ことができる。これにより、ページ遷移や共有がスムーズに行える。このような理由からSPAであってもルーティングを使っているアプリケーションが多い。 react-router と react-router-dom react-router には 3 種あり、コア機能だけがある 『react-router』、コア機能に Web に必要な機能を追加した 『react-router-dom』、ReactNative 用の 『react-router-native』が存在する。React で通常使用する際は、react-router-dom を使用する。react-router の方が正解に思えるが react-router-dom が通常使用する際は、正解。 react-router-domのインストール $ npm install --save react-router-dom 参考サイト React.jsでルーティングを実装するためのreact-routerの紹介 react-router-domでルーティングを作成する
- 投稿日:2021-12-03T09:03:32+09:00
知ってると得をするReactコンポーネントのイケてる書き方
はじめに 基本的にReact + TypeScriptでフロントの開発をしているんですが、実際にコードを書いている時に気をつけていること、便利な書き方として知っておくと得をするReactコンポーネントの書き方を紹介します。 Propsが多くなりすぎたら やたらpropsが多くなってしまうことありませんか?しかも同じような名称ばっかりを何回も書くことになるという。そうゆうときはできる限りショートハンドで書きましょう。 return ( <SampleComponent type={user.type} name={user.name} email={user.email} image={user.image} /> ) Componentに全てのPropsを渡す場合は下記のようにするとコード量がだいぶ減りますね。 return ( <SampleComponent {...user} /> ) Component側に渡すのが全てではなくいくつか決められたものの場合はこちらです。 const {type, name, email, image} = user return ( <SampleComponent {...{type, name, email, image}} /> ) とはいえ、ショートハンドを覚えるのも大事ですが、Propsが多すぎる場合にはそもそComponentの分割を検討した方が良いでしょう。 React.FCの拡張 ComponentをCSS modulesやCSS in JSで作る際に className をPropsで渡せるようにしたいですよね?もちろん下記のようにすればclassNameを渡せるんですけど、いちいち数十個、数百個もあるComponentに対して型定義するのは正直めんどくさい。 type Props = { className?: string } export const Component: FC<Props> = ({ children, className }) => { return <div className={classnames(style.heading, className)}>{children}</div> } こちらの React.FC 型を拡張する - Qiita を参考にさせてもらって、上記問題も無事解決! ざっくりやり方を説明すると別の型を用意して、declare module に新しい型を追加するだけ! プロジェクトの任意のディレクトリに type.ts を作ってReact.FCに className を追加した FCX を定義します。 declare module 'react' { type FCX<P = {}> = FunctionComponent<P & { className?: string }> } もちろん VFCも同じように作ることができます。 ComponentのタグをPropsで渡すようにする ページの見出しで使う用にComponentをh1で作ったはいいけど、ここはh2の方が適切じゃね?ってことありませんか。「あー、コンポーネントのタグ変えられたら楽なのに」ってなりますよね。それ、実はできます。 export const Heading: FCX<Props> = ({ children }) => { return <h1 className={classnames(style.heading, className)}>{children}</h1> } propsに渡すことでできます。注意点としては Component のように最初を大文字にしてください。というのもReactでコンポーネントを書くときは <Component></Component> のように最初の文字は大文字ですよね。それと同じです。 型はもちろん string でもいけるんですが、存在しないタグはダメなので、下記のように型ガードさせるようにしましょう。 type Props = { Component?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' } export const Heading: FCX<Props> = ({ children, Component = 'h1' }) => { return <Component className={classnames(style.heading, className)}>{children}</Component> } Function as children childrenにpropsを渡すことができるって知ってました?例えば下記のようにボタンをクリックしたらinput要素が現れるComponentを作ることもできます。 比較的同じような動作をしてかつ、同一ページに複数存在する可能性があるようなComponentに向いている気がしますね。例えば、モーダルとかドロップダウンメニューとか。 type Props = { children: (collapsed: boolean, toggleCollapse: () => void) => ReactNode; }; export const CollapsibleComponent: FC<Props> = ({ children }) => { const [collapsed, setCollapsed] = useState(false); const toggleCollapse = () => { setCollapsed(!collapsed); }; return <div>{children(collapsed, toggleCollapse)}</div>; }; export const HogeComponent = () => { return ( <div> // ・・・ <CollapsibleComponent> {(collapsed, toggleCollapse) => { if (collapsed) { return ( <div> <input type="text" value="React 太郎" /> <button type="button" onClick={toggleCollapse}> 閉じる </button> </div> ); } return ( <div> React 太郎 <button type="button" onClick={toggleCollapse}> 開く </button> </div> ); }} </CollapsibleComponent> // ・・・ </div> ) } Componentのジェネリクスの書き方 Componentに対してジェネリクス書けたらいいのにって時ありません?実は書けます。 今回は render props を使ったComponentでジェネリクスを使ってみましょう。 render propsは受け取ったpropsを描画に使うことができます。ほとんどFunction as Childrenと同じなんですけどね。以前はApollo Clientでもこのrender propsを押していたような気もします。今はhooks推しですね。 早速ですがReact Queryで取得したデータを render に渡して描画させてみます。まず全体像はこちら。 type Props<T> = { queryKey: string render: (queryResult: UseQueryResult<T>) => JSX.Element } export const Fetch = <T,>({ render, queryKey }: Props<T>): JSX.Element => { const query = useQuery(queryKey, async (): Promise<T> => api.get(queryKey), { enabled: !!queryKey }) if (query.isLoading) { return <h2>Loading...</h2> } return render(query) } export const Home: React.FC = () => { return ( <Fetch<Item[]> queryKey="items" render={({ data }) => { return ( <div> {data?.map((e) => { return <div key={e.id}>{e.title}</div> })} </div> ) }} /> ) } FetchコンポーネントではrenderとqueryKeyを受け取ってrenderにReact Queryで取得したqueryResultを渡しています。あと、 取得するデータによって型が違うので Fetch<T> のようにジェネリクスになっていますね。 で、ここで注意点なんですが、Componentでジェネリクスをアロー関数で使いたい場合は <T,> のように , を入れるようにしてください。それでできます。自分もこれは知らなかった。, をつけていないとReactプロジェクトだと「終了タグどうした!!!」って怒られますww また、loading中は Loading... と表示させて終わったら return render(queryResult) としています。 type Props<T> = { queryKey: string render: (queryResult: UseQueryResult<T>) => JSX.Element } export const Fetch = <T,>({ render, queryKey }: Props<T>): JSX.Element => { const query = useQuery(queryKey, async (): Promise<T> => api.get(queryKey), { enabled: !!queryKey }) if (query.isLoading) { return <h2>Loading...</h2> } return render(query) } // こっちの書き方でもOK! export function Fetch<T>({ render, queryKey }: Props<T>): JSX.Element { const queryResult = useQuery(queryKey, async (): Promise<T> => api.get(queryKey), { enabled: !!queryKey }) if (queryResult.isLoading) { return <h2>Loading...</h2> } return render(queryResult) } 次にFetchコンポーネントの使用部分をみてみます。まず目に着くのは <Fetch<Item[]> /> ですよね。なんとも気持ち悪い感じ笑 実はこれがComponentのジェネリクスの書き方なんです。render内で受け取ったqueryResultからdataを取り出せていますね。 export const Home: React.FC = () => { return ( <Fetch<Item[]> queryKey="items" render={({ data }) => { return ( <div> {data?.map((e) => { return <div key={e.id}>{e.title}</div> })} </div> ) }} /> ) } hooksの登場であまり活躍の場面がないrender propsですが、hooksと組み合わせることで使える場面は出てきそうですね。というかこのパターンだと毎回 Loading しなくていいしめっちゃ良さげです。 また、Componentのジェネリクスも覚えておくと便利ですね。例えば取得したデータを表示させるTableがあったとして、ジェネリクスがあれば汎用的に使えそうです。 オブジェクトリテラルをComponentに活用する 何でもかんでもif文とかswitch文で出しわけしてませんか?オブジェクトリテラルを活用するとスッキリ宣言的に書くことができますよ。 まずは条件分岐パターンです。よく見るやつですが、typeが追加されると変更箇所が ACCOUNT_TYPE と switch に追加する必要が出てきますね const SampleComponent = (account) => { const { type } = account; const ACCOUNT_TYPE = { ADMIN: "ADMIN", OPERATOR: "OPERATOR", VISITOR: "VISITOR", }; switch (type) { case ACCOUNT_TYPE.ADMIN: return <Admin />; case ACCOUNT_TYPE.OPERATOR: return <Operator />; case ACCOUNT_TYPE.VISITOR: return <Visitor />; default: return null; } }; オブジェクトリテラルを使うとこんな感じです。めっちゃスッキリ!!変更箇所もcomponentsに追加するだけ! const SampleComponent = (account) => { const {type} = account const components = { ADMIN: Admin, OPERATOR: Operator, VISITOR: Visitor }; const Component = components[type]; return <Component />; } おわりに はじめてのアドカレだったんで色々調べながら記事にしました。ReactのComponentは関数なので JavaScript、TypeScriptでできることはほぼほぼできるんですよね。 また、結構知らないこともあってしかも早速プロジェクトに実践できそうです。
- 投稿日:2021-12-03T08:41:02+09:00
SPAでのデータの流れ方を設計するための一つの考え方
はじめまして。プロダクトエンジニアのもっさんです。 この記事は READYFORアドベントカレンダー2021 、3日目の記事です。 はじめに READYFORでは今年の9月ごろに、プロジェクトを実行する実行者が、支援してくれた方々とのコミュニケーション及びリターンの管理機能などを管理するための機能を一新し、リリースしました。(詳細はこちら) 今回は、そこで新しく作成した SPA での State 管理についての話をします。 ページ構成 今回リリースした SPA の各ページでは、大きく分けてサーバーから取得したデータを表示するテーブル部分と、ユーザーが検索したい内容を入力するフォーム部分の 2 つに分かれます。検索内容は URL Parameter から渡され、 API のパラメータ及び、フォームのデフォルト入力値として利用されます。 このページが状態として持っている箇所は、大きく分けて以下の 3 つになります。 URL のパラメータ (Query) 検索フォーム (Form State) テーブル (Resource State) この State を何も考えずに扱うと State 間での不整合が生まれるため(formで検索した値がテーブルにだけ反映されて、URLパラメータへの反映を忘れてしまうなど) 、ひと工夫が必要です。 前提として以下のライブラリ、ツールが利用されています。 Next.js site: https://nextjs.org vercel が提供している React.js 製のフロントエンドフレームワークです。 Next.js は、本番環境に必要なすべての機能(ハイブリッド静的およびサーバーレンダリング、TypeScript サポート、スマートバンドリング、ルートプリフェッチなど)をゼロコンフィグで利用できます。 SWR site: https://swr.vercel.app Next.js の開発元である vercel が提供しているデータ取得のための React Hook ライブラリです。 SWR は、まずキャッシュからデータを返してからフェッチリクエストを送り、最後に最新のデータを持ってくるという戦略を取っています。 React Hook Form site: https://react-hook-form.com 高性能で柔軟かつ拡張可能な使いやすいフォームバリデーションライブラリです。 最小限のコードでフォームを作成でき、 TypeScript との親和性が非常に高いため、型安全なフォームを作成できます。 openapi-typescript site: https://github.com/drwpow/openapi-typescript openapi の定義から TypeScript の型を生成するための node ライブラリです。 pathpida site: https://github.com/aspida/pathpida Next.js の Page コンポーネントの定義から自動的に URL の型定義ファイルを生成するライブラリです。 型の依存関係 基本となる型定義は openapi-typescript を用いて、schema.yaml から自動的に型定義ファイルを生成しています。生成された型をもとに、API Query の型、Form で扱うための型及び、ページの Query Parameter の型を定義します。基本的には生成された型からエイリアスを貼り、必要な型を抽出、結合して、利用します。ただし、Form においては input の型の都合上、例外的に独自の型を宣言する場合がありますが、その場合でも型の宣言・利用は Form State と Form View 内で完結します。Form Hook が Props として受け取る型は加工せず、また、他のパートへの型の変更の影響を与えることはありません。 データフロー データフローの考え方において、以下の図のようなレイヤに分けて考えます。各モジュール、親コンポーネントのそれぞれのレイヤで、どのような責務を持つかを定義します。 Query このレイヤーでは、 Next.js の useRouter を利用して URL パラメータにアクセスします。 Query では以下の責務を持ちます。 ページに必要なパラメータの型定義(pathpida を利用) パラメータの型の変換と型の保証 必須パラメータのチェック(存在しない場合はエラーへの遷移) Template レイヤへの値の受け渡し Template このレイヤでは Query レイヤからのデータを受け取り Hook へのデータの受け流しを行います。また Hook と View の繋ぎ込みを行います。 表示の出し分け等は行いますが、基本的にロジックを持ちません。 Form State ユーザの操作により、変化する値を管理するレイヤーです。検索フォームの入力や、ページ情報、ソート情報の保持、ハンドラ定義などもこのレイヤでおこないます。 実装上は Hook として切り出していて、 State 管理には React Hook Form を利用しています。UI の制限により、State で保持する値や型を変更している場合には、この State 内に閉じた型を宣言します。データの変化がある場合は Next.js の push アクションを経由して URL の変更し、 Query レイヤーの State の変更します。 Resource State 受け取った Props を元に API からリソースの取得をおこないます。Form State と同じく実装上は Hook として切り出していて、State の管理及びリソースの取得には SWR を利用しています。 ユーザの操作によって検索条件が変更になった場合、Form の State から直接パラメータを受け取ることはせず、必ず Query と Template を経由してパラメータを受け取ります。 まとめ 今回のまとめた複数 State 管理の考え方は、 Flux のイメージから設計されています。複数モジュール間におけるデータの流れを一方向にし、ユーザの操作による検索パラメータの適用を push アクションに集約しています。これにより、複数の State の整合性が保ちやすくなりました。 明日はTakepepeさんによるフロントエンドのテストに関する話です!
- 投稿日:2021-12-03T05:55:19+09:00
【React】useEffectの基本的な使い方・活用術・注意点
おはこんばんちは、@ちーずです。 アドベントカレンダー3日目にして、すでに毎日記事を書くことの大変さを実感してます 本日のテーマはuseEffectです! useEffectとは useEffectとは、関数コンポーネントで副作用を実行するためのhookです。 ...と言われてもReactにおける副作用ってなんぞ?ってなりますよね。 Reactのコンポーネントの世界において、副作用はコンポーネントのライフサイクルのことを指します。(多分) ▼ コンポーネントのライフサイクル (クラスコンポーネントにおけるライフサイクルメソッド名を添えて) マウントされる (componentDidMount) 更新される (componentDidUpdate) アンマウントされる (componentWillUnmount) 書き方 // 第一引数: コールバック関数(任意でcleanup関数を返す) // 第二引数: 配列 useEffect(() => { // 実行したい処理 return () => { // cleanupの処理 } }, [input]) 毎回のレンダリング後に実行 第二引数を空にすることで、レンダリング毎に実行されます。 useEffect(() => { // 処理 }) コンポーネントはstateやpropsなどに変更がある度にレンダリングされてしまいます。 そのため予期しないケースでuseEffectが実行されてしまう可能性があるため、第2引数を空にすることは危険です。 初回のレンダリング後に実行 第二引数をの配列の中身を空にすると、 初回のレンダリング後のみ実行されます。 useEffect(() => { // 処理 }, []) 指定した値に変化があった時に実行 第二引数の配列に値を設定することで、 その値に変更がある度に実行することができます。 useEffect(() => { // 処理 }, [value])) 第二引数は、基本的にはlint(react-hooks/exhaustive-deps)とエディタの機能に力を借りて 自動で設定すればokです! useEffectの活用術 ステートの変更を監視して実行 いっちばん基本的な使われ方ですが、 ステートの変更を監視して処理を実行することができます。 ▼ 例 useEffect(() => { if (count < 10) return; console.log('countがもう10以上やで') }, [count]); 条件に当てはまらない、実行したくない場合は早期リターンしてあげることをおすすめします。 外側から実行条件を渡して汎用化 処理は同様だけど、処理を実行させる条件が異なるケースがあると思います。 そのような場合は、useEffectを別コンポーネントもしくはカスタムフックとして別途作成し、 条件をpropsや引数で渡せるようにします。 ▼ よく使うpropsや`引数 skip: 処理をスキップするか - falseの時のみ実行させる prepared: 準備ができたか - trueの時のみ実行させる onMounted / onUnMount: マウント または アンマウント で実行させる ▼ 例: カスタムフックを使用した例 export const useCustomHook = (skip: false) => { useEffect(() => { if (skip) return; // 共通化したい処理 }, [skip]) } カスタムフックとはなんぞ?と思った人にはおすすめの記事があります。(宣伝) ※ 活用術に関しては、発見次第今後追記していく予定です! useEffectの注意事項 一見便利なuseEffectですが、使い方を誤ると予期せぬバグを産んでしまいがちです。 そのため、どのようなバグが発生しがちか、どのように対処すべきかも理解した上で使えるとより良いです。 無限ループに気をつける useEffectは特に無限ループに陥りがちです。 ありがちなのが、ステートの更新をuseEffectの中で行っているが、 そのuseEffectはステートが更新されたら再度実行されるようになっていて、 更新 → 実行 をひたすら繰り返してしまうことです。 const [count, setCount] = useState(0) useEffect(() => { setCounter(prev => prev + 1) }, [count]) useEffectは、ちゃんと無限ループがどのようなケースで発生しうるかを理解して使うことが大事です。 ▼ 参考 メモリリーク対策をしっかりする メモリリークとは、実行中のプログラムが割当てたメモリ領域を解放し忘れることで起きるバグです。 useEffectを使った場合、非同期の処理においてResponse返ってくるよりも先にコンポーネントが消失した場合に発生しうります。 その場合は、ちゃんとコンポーネントがマウントされた状態であることを判定した上で実行しましょう。 ▼ 例 useEffect(() => { let isMounted = true; // promise() - Promiseを返す何かしらの処理 promise().then((n) => { isMounted && setState(n); }); // アンマウント時にmountedをfalseに変更 return () => { isMounted = false; }; } ▼ 参考 イベントを追加した時は、クリーンアップ時に削除する addEventListenerをuseEffect内で使う場合、 useEffectが走る度にイベントが追加され続けてしまいます。 そのため、クリーンアップ時にイベントを一度削除するよう制御しましょう。 ▼ 例 useEffect(() => { window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, [handleResize]); 以上、「useEffectの基本的な使い方と活用術」でした! 明日はuseRefの基本的な使い方と活用術に関してです! お楽しみに
- 投稿日:2021-12-03T04:01:32+09:00
React Hook Form v7でMaterial UI v5 Selectを使う方法
問題 react-hook-formが便利なので使っていたところ、MUI v5 のTextFieldでは簡単に動いたのですが、Selectは同じノリで使えませんでした。 失敗例 import React from "react"; import { SubmitHandler, useForm } from "react-hook-form"; import Button from "@mui/material/Button"; import TextField from "@mui/material/TextField"; import Select from "@mui/material/Select"; import MenuItem from "@mui/material/MenuItem"; type SampleForm = { postalCode: string; prefecture: string; // Selectで選びたいもの city: string; address: string; building: string; }; export const Sample: React.FC = () => { const { register, handleSubmit } = useForm<StoreAddForm>(); const addAddress: SubmitHandler<SampleForm> = (input) => { console.log(input); }; return ( {/* 省略 */} <Select sx={{ mt: 2 }} required fullWidth label="都道府県" {...register("prefecture")} > <MenuItem value="東京">東京</MenuItem> <MenuItem value="埼玉">埼玉</MenuItem> <MenuItem value="千葉">千葉</MenuItem> </Select> {/* 省略 */} <Button type="submit" onClick={handleSubmit(addAddress)} fullWidth variant="contained" sx={{ mt: 3, mb: 2 }} > 登録 </Button> ); }; 依存関係 "dependencies": { "@mui/material": "^5.2.1", "@types/react": "^17.0.0", "@types/react-dom": "^17.0.0", "react": "^17.0.2", "react-dom": "^17.0.2", "react-hook-form": "^7.20.5", "typescript": "^4.1.2", } 解決方法 Controllerを使うと動きました。同じ部分は削除したわけではなく、省略してます。 import { SubmitHandler, useForm, Controller } from "react-hook-form"; // Controllerを追加 export const Sample: React.FC = () => { const { register, handleSubmit, control } = useForm<StoreAddForm>(); // controlを追加 return ( {/* 省略 */} <Controller name="prefecture" control={control} render={({ field }) => ( <TextField {...field} select sx={{ mt: 2 }} required fullWidth label="都道府県" > <MenuItem value="東京">東京</MenuItem> <MenuItem value="埼玉">埼玉</MenuItem> <MenuItem value="千葉">千葉</MenuItem> </TextField> )} /> {/* 省略 */} ); }; (補足) Selectコンポーネントではない件について Selectを使っても動作はしましたが、なぜかlabel指定が効かず、"都道府県"のラベルが画面に出ませんでした。 TextFieldにselect属性を付ける方法だとlabelが出ます。 コードは関係なさそうなものを適当に省略しているため、分からなかったら聞いてください。 React Hook FormやMaterial UIは詳しくないので、動作保証はしかねます。 間違っていれば教えてください。