React のテストは react-dom/test-utils から @testing-library/react に進むと理解しやすそう
の続きです。React のテスト用ライブラリを色々試した結果 react-dom/test-utils をちょっと触った後で、 @testing-library/react を導入するといいんじゃないかなと思いました。学習順序的な話です。
react-dom/test-utils は React のテストを素朴で馴染みの深いDOM・イベント操作で行えることを確認できます。 ただし記述量が多くボイラープレート的なコードも必要にります。 そこで @testing-library/react を導入するとテストコードが簡潔になります。
@testing-library は高機能ですがその分どのAPIをどう使うべきかで初めは迷います。 react-dom/test-utils を利用した手法を知っていると「とりあえず element だけ捕まえてあとは低レベルな操作でなんとかする」という選択肢がとれるので、初めに react-dom/test-utils を触っておくのは有用だと思いました。
今回は Jest 上で試しました。
比較してみる
スナップショットテストとイベントの発火を伴う簡単なテストを書いて両者のコードを比較していきます。
テスト対象のコンポーネント
コンポーネント内の要素を取得する方法も確認したかったので div
の子要素にカウントアップボタンを持つコンポーネントを用意しました。
import React, { useState } from 'react' function CountButton() { const [count, setCount] = useState(0) const increment = () => { setCount(s => s + 1) } return <button onClick={increment}>{count}</button> } export default function MyComponent() { return ( <div> <CountButton /> </div> ) }
react-dom/test-utils
初めは react-dom/test-utils です。
import React from 'react' import { render } from 'react-dom' import { act } from 'react-dom/test-utils' import MyComponent from './MyComponent' // 使い回すコンテナ要素 let container: HTMLDivElement beforeEach(() => { container = document.createElement('div'); document.body.appendChild(container); }); afterEach(() => { document.body.removeChild(container); }); test('MyComponent renders fine :)', () => { render(<MyComponent />, container) expect(container).toMatchSnapshot() }) test('MyComponent works fine :)', () => { render(<MyComponent />, container) const button = container.querySelector('button') if (!button) throw new Error('button not found') expect(button.textContent).toBe('0') act(() => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) expect(button.textContent).toBe('1') })
DOMのAPIと同様 container.querySelector
で子要素であるボタンを取得できます。
act
内での dispatchEvent
でイベントをディスパッチすることでクリックイベントを発火します。
普段こういうAPIは使わないので、イベントの発火ってこういう風にするんだなとかちょっと新鮮でした。
基本的に document.body
以下のDOMを操作しているだけのコードに見えるので、
React のテストと言えど素朴な感じで書けることがわかりますね。
Node.js 上で動いているはずなので document
へのアクセスはどうやっているのか若干お膳立てされている感じがありますが、多分 Jest が jsdom なんかをいい感じにセットアップしてくれているのでしょう。
スナップショットも普通に動きました。
// Jest Snapshot v1, https://goo.gl/fbAQLP exports[`MyComponent renders fine :) 1`] = ` <div> <div> <button> 0 </button> </div> </div> `;
react-dom だけでこれが得られるなら react-test-renderer は特に無くてもいい気がしましたが、shallow レンダリングしたい場合は必要になるとか、そんな感じでしょうか。
@testing-library/react
次に @testing-library/react で同様の処理を書いていきます。
import React from 'react' import { render, cleanup, fireEvent } from '@testing-library/react' import MyComponent from './MyComponent' beforeEach(cleanup) test('MyComponent renders fine :)', () => { const { container } = render(<MyComponent />) expect(container).toMatchSnapshot() }) test('MyComponent works fine :)', () => { const { container } = render(<MyComponent />) const button = container.querySelector('button') if (!button) throw new Error('button not found') expect(button.textContent).toBe('0') fireEvent.click(button) expect(button.textContent).toBe('1') })
だいぶ簡潔に書けていますね。
セットアップの
let container: HTMLElement beforeEach(() => { container = document.createElement('div'); document.body.appendChild(container); }); afterEach(() => { document.body.removeChild(container); });
が省けているのが楽です。テストファイルが多くなるとこの差は大きくなると思います。
クリーンナップ処理は beforeEach(cleanup)
で行います。
container
要素を作ってそこにテスト対象のコンポーネントをレンダリングするのは同様なのですが
render
関数にその処理が含まれていて返り値で container
要素が帰ってくるのが楽ですね。
型も react-dom/test-utils の例と同様 HTMLElement
なのでスナップショットを撮ったり querySelector
を生やせたりと同じ使い方ができます。
他にイベント発火も
act(() => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
から
fireEvent.click(button)
と楽になりました。テストは簡潔に書けるに越したことはないですね。
react-dom/test-utils のコードを書いた後だと、だいたい裏でどんな処理がなされているかが想像できて良いです。
enzyme と react-test-renderer と @testing-library/react を素朴に試す
React のテスト用ライブラリっていくつかある上に、それぞれサポートしている機能が違うんですよね。 ついでに提供されているメソッドや、メソッドチェーンの様式も様々なので、出来るだけ速やかに繰り返し実行できる環境で触りながら把握できると便利です。 Jest でコンソール出力しながらだとまどろっこしいので、手元でサクサク試せる環境をセットアップした時のメモです。
React のテスティングライブラリ
今回対象にするのは以下の3つのライブラリです。現時点の理解を書き添えてみました。
- enzyme
- react-test-renderer
- スナップショットを取ることができる
- @testing-library/react
fireEvent
でイベントを発火することができる
セットアップ
package.json
の抜粋です。エラーを消すために色々追加していったら、こんな感じで落ち着きました。
{ "dependencies": { "@testing-library/react": "^8.0.7", "enzyme": "^3.10.0", "enzyme-adapter-react-16": "^1.14.0", "jsdom": "^15.1.1", "jsdom-global": "^3.0.2", "nodemon": "^1.19.1", "react": "^16.8.6", "react-dom": "^16.8.6", "react-test-renderer": "^16.8.6" } }
nodemon
はファイルを監視させて再実行させるために利用しています。お試し環境の良き友ですね。
今回は省けるだけ環境構築を省く、という方針でセットアップしたので、JSX は利用できなくなっています。
小規模なら React.createElement
を直接利用してもそこまで大変ではありませんでした。
それぞれ試していく
テスト対象のコンポーネント
イベントや多少のネストなんかを確認できるようにしてみました。
const { createElement: e, useState } = require('react') // Sample Components // ----------------- const CountButton = () => { const [count, setCount] = useState(0) return e( 'button', { onClick: () => setCount(c => c + 1) }, `count: ${count}` ) } const C1 = props => e('div', {}, 'C1: ', props.content) const C2 = () => e('div', {}, e(C1), e(C1, { content: 'content-from-C2' }), e('div', {}, e(CountButton), e(CountButton), e('div', {}, e(C1) ) ) ) module.exports = { CountButton, C1, C2 }
enzyme
const { createElement: e } = require('react') const enzyme = require('enzyme') const Adapter = require('enzyme-adapter-react-16') const { shallow } = enzyme const { C1, C2 } = require('./components') enzyme.configure({ adapter: new Adapter() }) console.log(shallow(e(C2)).debug()) console.log(shallow(e(C2)).find(C1).at(1).dive().debug())
これを実行すると
<div> <C1 /> <C1 content="content-from-C2" /> <div> <CountButton /> <CountButton /> <div> <C1 /> </div> </div> </div> <div> C1: content-from-C2 </div>
と出力されます。enzyme の shallow
ってこういうことなんだなとか、
この段階ではこういうオブジェクトが帰ってきているんだなとかを眺めるための環境です。
試しに C1
内でエラーを投げるようにしてみたのですが、問題なく同じ出力がされました。
react-test-renderer
const { createElement: e, useState } = require('react') const { create } = require('react-test-renderer') const { C1, C2 } = require('./components') console.log(create(e(C2)).toTree()) console.log(create(e(C2)).toJSON())
これを実行すると
{ nodeType: 'component', type: [Function: C2], props: {}, instance: null, rendered: { nodeType: 'host', type: 'div', props: { children: [Array] }, instance: null, rendered: [ [Object], [Object], [Object] ] } } { type: 'div', props: {}, children: [ { type: 'div', props: {}, children: [Array] }, { type: 'div', props: {}, children: [Array] }, { type: 'div', props: {}, children: [Array] } ] }
@testing-library/react
動作させるには jsdom-global
を実行しておくことが必要です。
Jest と組み合わせて使う場合には不要です。
const { createElement: e } = require('react') const { render, fireEvent } = require('@testing-library/react') require('jsdom-global')() const { C2 } = require('./components') const { getAllByText, debug } = render(e(C2)) debug() fireEvent.click(getAllByText(/count/)[0]) debug()
以下のように出力されます。shallow
ではないので enzyme
の表示に比べて長いですね。
<body> <div> <div> <div> C1: </div> <div> C1: content-from-C2 </div> <div> <button> count: 0 </button> <button> count: 0 </button> <div> <div> C1: </div> </div> </div> </div> </div> </body> <body> <div> <div> <div> C1: </div> <div> C1: content-from-C2 </div> <div> <button> count: 1 </button> <button> count: 0 </button> <div> <div> C1: </div> </div> </div> </div> </div> </body>
テスティングライブラリは大量のメソッド・ユーティリティが存在するので、TypeScript で構築すれば良かったです。
Reactコンポーネントのアンマウント後の更新を避けるために AbortController を使ってみる
これです。
Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function. in UserList (at App.tsx:53)
この warning を消すために useEffect
のクリーンアップ処理をきちんと実装してみようと思います。
動作するコードは github に置いてあります。
AbortController
fetch
を中断するために AbortController というものが利用できるみたいです。Can I use によるとモダンブラウザなら普通に使えるようです。
使い方は AbortController
を new
してメンバの signal
を fetch
に仕込むと AbortController
インスタンス経由でその fetch
を中断できる、というものです。
const controller = new AbortController() const { signal } = controller fetch('http://localhost:3080/', { signal })
useEffect 内で利用してみる
以上のサイトを参考に fetch
が完了する前にアンマウントされた場合にクリーンアップ処理で fetch
を中断するようなコードを書きました。useEffect
で返される関数がアンマウント時(もしくは同じ useEffect
が再実行された時)に呼ばれクリーンアップ処理として作用します。
interface Data { // data structure... } const UserList: React.FC = () => { const [data, setData] = useState<Data>() const [error, setError] = useState<Error>() useEffect(() => { const controller = new AbortController() const { signal } = controller let finished = false fetch('http://localhost:3080/', { signal }) .then(res => res.json()) .then(setData) .catch(err => { // `abort` した後に `setError` してしまうと結局冒頭のエラーが出るので弾く if (err.name !== 'AbortError') setError(err) }) .finally(() => { finished = true }) return () => { if (!finished) { controller.abort() console.log('UserList: Fetch aborted') } } }, [setData, setError]) if (error) return <div>{error.toString()}</div> if (!data) return <div>Loading...</div> return ( // Render data... ) }
もう少し簡潔に書けると思ったのですが、とりあえずこんな感じです。
controller
で fetch
が終了したかどうかも取得できれば finished
なんて変数を利用しなくてもいいんですけどね。
型定義を見る限り aborted
しか取れなさそうです。
// lib.dom.d.ts interface AbortController { readonly signal: AbortSignal; abort(): void; } interface AbortSignal extends EventTarget { readonly aborted: boolean; onabort: ((this: AbortSignal, ev: Event) => any) | null; addEventListener<K extends keyof AbortSignalEventMap>(type: K, listener: (this: AbortSignal, ev: AbortSignalEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void; removeEventListener<K extends keyof AbortSignalEventMap>(type: K, listener: (this: AbortSignal, ev: AbortSignalEventMap[K]) => any, options?: boolean | EventListenerOptions): void; removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void; }
https://blog.jxck.io/entries/2017-07-19/aborting-fetch.html の記事を読みながら使えるようになるまでに色々あったんだなと思いつつ、2019 年現在では普通に使えているようで何よりです。
React で Markdown を扱うときに便利な react-markdown と remove-markdown
Markdown 便利ですよね。使う側にとってはテキストベースでリッチコンテンツを表現できますし、開発者側にとってもアプリを実装する際に Markdown 対応さえしてしまえば、ある程度の表現力を確保できるとともに、XSS対応などもライブラリ側がしてくれている場合が多いです。Markdown はユーザから投稿されるコンテンツを扱う場合の簡便な選択肢の一つですね。
ウェブアプリとして Markdown を扱う際に必要なのは HTML として表示するためのパーサ・レンダラーです。そしてもう一つ、サマリや概要表示をする際には Markdown の見出しや図表といったマークアップを除外するライブラリがあると便利です。
今回は以下のライブラリを利用して、React で Markdown を扱う簡単なアプリを作成してみました。
最終的にこんな感じに表示されます。
動作するコードは github に置いておきました。
Markdown 表示アプリの実装
といっても説明すべきことはあまりなく、ブログエントリの構造体として
export interface BlogEntry { title: string body: string }
を定義し、アプリケーションロジックとしては以下の実装がほぼ全てになります。
import React from 'react' import Markdown from 'react-markdown' import { HashRouter, Route, NavLink, Link, Switch } from 'react-router-dom' import removeMarkdown from 'remove-markdown' import './App.css' import { entries } from './data' const App: React.FC = () => { return ( <HashRouter> <div className="App"> <nav className="blog-list"> <div className="blog-list-header">My Blog App</div> {entries.map((e, i) => ( <NavLink className="blog-item" activeClassName="blog-item-active" to={`/blog/${i}`}> <div className="blog-item-title">{e.title}</div> <div className="blog-item-body">{removeMarkdown(e.body)}</div> </NavLink> ))} </nav> <main className="main-content"> <Switch> <Route path="/blog/:index" render={({ match: { params: { index }} }) => { const e = entries[index] return ( <div className="blog-detail"> <nav className="breadcrumbs"> <Link to="/">Blog Entries</Link> / {e.title} </nav> <div className="blog-title">{e.title}</div> <Markdown className="blog-body" source={e.body} /> </div> ) }} /> <Route path="/" render={() => ( <div className="home-content"> <h1>Welcome to My Blog</h1> <p>Select Blog Post!</p> </div> )} /> </Switch> </main> </div> </HashRouter> ) }
スタイルを当てるための className
がうるさくて見づらいですが、要点としては Markdown
形式のテキストデータをそれぞれのライブラリに渡しているだけです。
- サイドバー:
remove-markdown
に渡してマークアップを外した文字列を得る- それを CSS の
text-overflow: ellipsis
で省略表示に - もしくは JavaScript 側で
substring
するのでも可能
- それを CSS の
- 本文:
react-markdown
コンポーネントのsource
に 流し込むだけ
簡単すぎますね。ありがたいことです。
付録: Markdown のマークアップを外す他の方法
remove-markdown を探すまでに remark 方面から探していたら strip-markdown というライブラリも見つけたので、ついでにそのことも書いておきます。
remark というのは react-markdown 内でも使われている Markdown パーサで、Markdown からASTを得る部分の処理を行っています。remark はさらに内部で unified というテキスト処理のための汎用インタフェースを提供するライブラリを使っているようです。unified はパースや出力時の処理を外部ライブラリから指定でき、remark はパース処理を Markdown 用に固定した unified と言えそうです。
実際 remark の実装を見ると以下のようになっています。
var unified = require('unified') var parse = require('remark-parse') var stringify = require('remark-stringify') module.exports = unified() .use(parse) .use(stringify) .freeze()
そして strip-markdown はその remark から
remark().use(stripMarkdown).processSync()
のように利用するライブラリです。細かいオプションには差異がありますが大枠の動作としては remove-markdown と同じように利用できます。ただ処理の内容として、Markdown 形式の文字列を
という違いがあるので単純な置換処理である remove-markdown の方が軽い気がします。バンドルサイズとしても react-markdown と併用するという前提では unified と remark-parse 分は共用になるので無視するとしても remark-stringify 分は必要になります。package.json の dependencies を増やしたくないという観点では、remove-markdown の方に軍配をあげたくなります。
remark や unified について調べられて面白かったのですが、最終的に remove-markdown を見つけてちょっと肩透かしな気分になったのでした。
以上、Markdown 方面の React、JavaScript 対応状況を調べて試してみました。
useEffect の評価タイミングが分からないので泣きながら試す
useEffect
と useState
を組み合わせた Hook を作成しようとしたのですが、動作のタイミングが今ひとつわからずうまく処理が噛み合わない Hook ができてしまったので、簡単な例で挙動を探っていきます。
色々試してみましたが、結局よくわかりませんでした。以下の内容もいつも以上に理解しないままで書いていますがご容赦ください。
動作するコードは github に置いてあります。
useState と useEffect を組み合わせたシンプルな例
const useMyHook = (label: string) => { console.log(label, 'useMyHook') const [s1, setS1] = useState(0) useEffect(() => { setS1(s => { console.log(label, `useEffect(s1): increments s1(${s1} → ${s1+1})`) return s + 1 }) }, []) return { s1 } } let count = 0 const App: React.FC = () => { count++ const label = '-'.repeat(count-1) + `[${count}]` console.log(label, 'App') const state = useMyHook(label) console.log(label, `App: got ${JSON.stringify(state)}`) console.log(label, 'App: render') return ( <div className="App"> <header className="App-header"> {JSON.stringify(state)} </header> </div> ) }
関数コンポーネント App
が useMyHook
を利用しています。useMyHook
の中では useState
と useEffect
を利用しており useEffect(fn, [])
は fn
をマウント時の一度のみ実行します。
実行タイミングがわかりやすいように、評価回数をカウントしてコンソール出力のインデントとして表示します。
実行後のコンソール出力は以下のようになりました。実装を読んでいないので適当ですが、こんな流れだろうという解説を添えてみました。
console | 解説 |
---|---|
[1] App | |
[1] useMyHook | 最初に useMyHook を評価した時点では... |
[1] App: got {"s1":0} | ...初期状態の state が帰ってくる |
[1] App: render | App がレンダリング内容を返す |
[1] useEffect(s1): increments s1(0 → 1) | マウント時に呼ばれるように指定した useEffect が実行される |
-[2] App | useState の状態が更新されたので App が再評価される |
-[2] useMyHook | 再び useMyHook を評価し... |
-[2] App: got {"s1":1} | ...s1 が更新された state を得る |
-[2] App: render |
注目したいのは useEffect
の実行されるタイミングで、useMyHook
内で定義された時点では実行されずに、それを呼び出した App
が レンダリング内容を返した後で 実行されています。公式のドキュメントにも書いてありますね。
https://reactjs.org/docs/hooks-effect.html
What does useEffect do? By using this Hook, you tell React that your component needs to do something after render.
これを把握していないと、私のように「なんか副作用的な処理がうまく噛み合わない...」と処理を作り込んだ後で後悔することになります。実際、当初の私の理解では上記のコードを以下のように誤読していました。
- 初回に
useMyHook
が返す値はuseEffect
で更新された後の値{"s1":1}
- なので
App
は一度のみ評価される
実際は App
評価 → useEffect
評価 → App
再評価、という順で評価されます。余談ですが私のやろうとしていたことをクラスコンポーネントのライフサイクルメソッドで実装しようとすると
https://reactjs.org/docs/react-component.html
の UNSAFE_componentWillMount
や UNSAFE_componentWillUpdate
に相当しそうなので、React 的にはそもそもあまりよろしくない動作を実装しようとしていたことになりますね。
もう少し複雑なパターン
const useMyHook = (label: string) => { console.log(label, 'useMyHook') const [s1, setS1] = useState(0) const [s2, setS2] = useState(0) // will get increment by s1 update useEffect(() => { setS1(s => { console.log(label, `useEffect(s1): increments s1(${s1} → ${s1+1})`) return s + 1 }) }, []) useEffect(() => { setS2(s => { console.log(label, `useEffect(s1→s2): increments s2(${s2} → ${s2+1}) caused by s1(${s1}) update`) return s + 1 }) }, [s1]) return { s1, s2 } }
s2
を更新するための useEffect
を s1
の更新に対して実行されるように追加しました。全体の更新のトリガーはマウント時に1回だけ更新される s1
のみです。
実行後のコンソール出力は以下のようになりました。もうよく分からないですね。
console | 解説 |
---|---|
[1] App | |
[1] useMyHook | |
[1] App: got {"s1":0,"s2":0} | |
[1] App: render | |
[1] useEffect(s1): increments s1(0 → 1) | #1 |
-[2] App | #1 による再評価 |
-[2] useMyHook | |
[1] useEffect(s1→s2): increments s2(0 → 1) caused by s1(0) update | #2 |
-[2] App: got {"s1":1,"s2":1} | s1 も s2 も更新された値を同時に得る(タイミングが謎) |
-[2] App: render | |
--[3] App | #2, #3 両方による再評価...? |
--[3] useMyHook | |
-[2] useEffect(s1→s2): increments s2(1 → 2) caused by s1(1) update | #3 |
--[3] App: got {"s1":1,"s2":2} | |
--[3] App: render |
二つの useEffect
の評価されるタイミングの間に App
の評価タイミングが割り込んでいるのが気になりますね。
あと App
の再評価と State の更新のタイミングが前後しているように見えるのが気になります。
短期間に複数の useEffect
内で State が更新されたせいでこんな挙動になっているのでしょうか。
各 useEffect
に対して 0
に指定した setTimeout
を適用してみると App
の評価回数が1回多く増えました。
| [1] App | | | [1] useMyHook | | | [1] App: got {"s1":0,"s2":0} | | | [1] App: render | | | [1] useEffect(s1): increments s1(0 → 1) | #1 | | -[2] App | #1 による再評価 | | -[2] useMyHook | | | -[2] App: got {"s1":1,"s2":0} | #1 が反映 | | -[2] App: render | | | --[3] App | ...おそらく #2 による再評価 | | --[3] useMyHook | | | [1] useEffect(s1→s2): increments s2(0 → 1) caused by s1(0) update | #2 | | --[3] App: got {"s1":1,"s2":1} | #2 が反映 | | --[3] App: render | | | ---[4] App | ...おそらく #3 による再評価 | | ---[4] useMyHook | | | -[2] useEffect(s1→s2): increments s2(0 → 1) caused by s1(1) update | #3 | | ---[4] App: got {"s1":1,"s2":2} | #3 が反映 | | ---[4] App: render | |
JavaScript のタイマー処理は目的の時間ちょうどには行われない、ということによるものなのでしょうか。ただ、動作的にはこちらの方がしっくりくる気がします。が、依然として useEffect とコンポーネントの再描画のタイミングがよく分からないことになっています
もっと複雑なパターン
const useMyHook = (label: string) => { console.log(label, 'useMyHook') const [s1, setS1] = useState(0) const [s2, setS2] = useState(0) // will get increment by s1 update const [s3, setS3] = useState(0) // will get increment by s2 update useEffect(() => { setS1(s => { console.log(label, `useEffect(s1): increments s1(${s1} → ${s1+1})`) return s + 1 }) }, []) useEffect(() => { setS3(s => { console.log(label, `useEffect(s2→s3): increments s3(${s3} → ${s3+1}) caused by s2(${s2}) update`) return s + 1 }) }, [s2]) useEffect(() => { setS2(s => { console.log(label, `useEffect(s1→s2): increments s2(${s2} → ${s2+1}) caused by s1(${s1}) update`) return s + 1 }) }, [s1]) return { s1, s2, s3 } }
s1
と s2
の useEffect
の定義の間に s2
の更新タイミングによる s3
更新処理を挟みました。
console | 解説 |
---|---|
[1] App | |
[1] useMyHook | |
[1] App: got {"s1":0,"s2":0,"s3":0} | |
[1] App: render | |
[1] useEffect(s1): increments s1(0 → 1) | #1 |
-[2] App | #1 による再評価 |
-[2] useMyHook | |
[1] useEffect(s1→s2): increments s2(0 → 1) caused by s1(0) update | #2 |
[1] useEffect(s2→s3): increments s3(0 → 1) caused by s2(0) update | #3 |
-[2] App: got {"s1":1,"s2":1,"s3":1} | #1, #2, #3 が反映 |
-[2] App: render | |
--[3] App | |
--[3] useMyHook | |
-[2] useEffect(s1→s2): increments s2(1 → 2) caused by s1(1) update | #4 |
-[2] useEffect(s2→s3): increments s3(1 → 2) caused by s2(1) update | #5 |
--[3] App: got {"s1":1,"s2":2,"s3":2} | #4, #5 が反映 |
--[3] App: render | |
---[4] App | #4, #5 |
---[4] useMyHook | |
--[3] useEffect(s2→s3): increments s3(2 → 3) caused by s2(2) update | #6 |
---[4] App: got {"s1":1,"s2":2,"s3":3} | #6 が反映 |
---[4] App: render |
やっぱり App
の評価後に実行された useEffect
の結果が useMyHook
の返り値に反映されていますね...よくわからないです。useMyHook
の評価が始まった後で、それ以前の useEffect
が処理されているように見えるのでもしかしたら useEffect
の定義時に同じ箇所の useEffect
が呼ばれたかを確認して、呼ばれていなければそのタイミングで呼ぶ、みたいな処理をしている...とか...? でもそれだと useState
の値がよくわからなく...
ちょっとお手上げです。振る舞いから推測するより React Hook の実装を理解した方が近道かもしれません。
気が向いたら眺めますが、今回は以上です。
要点としては useEffect
の評価タイミングはレンダリング後、ということで、それ以上のことはよくわかりませんでした。コンポーネントの再描画のタイミングと useState
のタイミングが前後してコンソールに出力される理由がよく分からないままです。
もしかしたらコンソール出力の部分がよくないのかもしれませんね。
Prettier のデフォルト設定を ~/.prettierrc に置く
最近 React のちょっとした挙動を確認するために crate-react-app
でプロジェクトを作成することが多くなりました。;
いらない派の私としては生成されたファイルをそのまま使いたくないので、毎回 Prettier を適用するために .prettierrc
をプロジェクトルートに置いていました。しかし親階層、例えばホームディレクトリに置いておいても良いみたいです。
https://prettier.io/docs/en/configuration.html
The configuration file will be resolved starting from the location of the file being formatted, and searching up the file tree until a config file is (or isn't) found.
なので設定ファイルが見つかるまで親ディレクトリへと辿って行ってくれるみたいですね。Prettier は設定ファイルに cosmiconfig というライブラリを利用しているらしく、この動作もそれによるものみたいです。今度何かツールを作る機会があれば cosmiconfig を試してみたいです。
プロジェクト自体に .prettierrc
が含まれる場合はそちらが優先される(と思う)ので、とりあえず自分用のデフォルト設定として ~/.prettierrc
に置いて運用してみようかと思います。
ReactRouter のページ遷移で値が切り替わるように Formik の入力値を localStorage に保存する
Formik のフォームの値を localStorage
に保存しておくライブラリとして formik-persist というものがありますが、ReactRouter でページ遷移した際に値がうまく切り替わらなかったので自前で Hook を組んで何とか実装してみます。
経緯としては以下の通りです。
- formik-persist を使ったけどページ遷移しても遷移前のフォームの入力値が残り続ける
- せっかくなので
localStorage
を使って自前で処理を書いてみよう - react-use の
useLocalStorage
を使ってみたけどkey
が変更されても値が切り替わらないので結局同じ問題が発生する - 好みの挙動になるように自前で
useLocalStorage
Hook を実装しよう (˘꒳˘) 意識高い
もしかしたら key
を適切に与えれば解消するような、react-router でよくある感じのものなのかもしれません。
動作するコードは以下のリポジトリに置いてあります。
useLocalStorage を定義する
useLocalStorage
のコードは https://usehooks.com/useLocalStorage/ を始め色々見つかりますので参考にしつつ書いていきます。
import { useState, useEffect, useCallback } from 'react' function getPersistedValue<V>(key: string, defaultValue: V) { const v = window.localStorage.getItem(key) if (!v) return defaultValue try { return JSON.parse(v) as V } catch (err) { console.warn(err) return defaultValue } } function persistValue<V>(key: string, value: V) { try { const v = JSON.stringify(value) window.localStorage.setItem(key, v) } catch (err) { console.warn(err) } } // Hook export default function useLocalStorage<V>(key: string, initialValue: V) { const [value, setValue] = useState(() => getPersistedValue(key, initialValue)) // `key` が変わったら値を再取得する (*˘꒳˘*) useEffect(() => { setValue(getPersistedValue(key, initialValue)) }, [key, setValue, initialValue]) // 値を更新しつつ `localStorage` に保存する (*˘꒳˘*) const setValuePersistently = useCallback((v: V) => { persistValue(key, v) setValue(v) }, [key, setValue]) // ちょっと色気を出してクリア用のメソッドも提供してみる (*˘꒳˘*) const clearValue = useCallback(() => { setValuePersistently(initialValue) }, [setValuePersistently, initialValue]) return [value, setValuePersistently, clearValue] as const }
今回の肝は key
の変更に合わせて対応する localStorage
の値で value
を更新することと、関連するハンドラも忘れずに更新されるようにしておくことです。
Formik と合わせる
Formik と組み合わせて以下の動作を実現します。
initialValues
が変更されたらフォームの内容を新しいinitialValues
でリセットするonChange
でフォームの値をlocalStorage
に保存するonSubmit
でフォームの値を初期化する- 送信後コメントフォームを空にするというケースを想定
interface FormValue { title: string body: string } const createInitialValue = () => ({ title: '', body: '' }) const FormView: React.FC<RouteComponentProps<{ id: string }>> = props => { const { id } = props.match.params const formKey = `FormView:${id}` const initialValues = useMemo(createInitialValue, []) const [values, persistValues, clearValues] = useLocalStorage( formKey, initialValues ) // `Formik` には `onChange` がないので `validate` で代用する (*˘꒳˘*) きたない const fakelyValidateToHandleChange = useCallback( (v: FormValue) => { persistValues(v) return Promise.resolve() }, [persistValues] ) return ( <Formik initialValues={values} onSubmit={clearValues} validate={fakelyValidateToHandleChange} validateOnChange enableReinitialize > {() => ( <Form> <Field name="title" component="input" /> <Field name="body" component="textarea" /> <button type="submit">Submit</button> </Form> )} </Formik> ) }
ポイントとしては以下の通りです。
enableReinitialize
を指定してinitialValues
の変更時にフォームの値を新しいinitialValues
の値でリセットする- ページ遷移時に
initialValues
が変わってフォームの内容が遷移後のページのものに更新されるように
- ページ遷移時に
validate
をonChange
として転用- 本当はこんなことやりたくないのですが...
残念なポイントとしては、上記のダーティハックはもちろんですが、せっかく Formik が State を管理してくれているのに、それをわざわざ親コンポーネントに引っ張り上げて二重管理をしてしまっている所です。
initialValues
が実質 Controlled Component の value
みたいな扱いになってしまっているので、ちょっと気持ち悪いです。
React でコンポーネントを組むときに、どうしようもない場合によく現れるパターンな気がしますが、スッキリしないですね。
とりあえず、一応動いたので今回は良しとします。気が向いたら formik-persist のコードも見ながら他にいい方法がないか調べてみます。
追記
以下のように Formik
と Persist
双方に key
を設定すればページ遷移しても、適切に入力値が保存されたものに切り替わるようになりました。
const FormView: React.FC<RouteComponentProps<{ id: string }>> = props => { const { id } = props.match.params const formKey = `FormView:${id}` const initialValues = useMemo(createInitialValue, []) return ( <Formik initialValues={initialValues} onSubmit={console.log} enableReinitialize key={formKey} > {() => ( <Form> <Field name="title" component="input" /> <Field name="body" component="textarea" /> <button type="submit">Submit</button> <Persist name={formKey} key={formKey} /> </Form> )} </Formik> ) }
これで一件落着したような気がします。react-persist は connect
を使ってこんな風に Formik
にアクセスできるんだなっていう良い例ですね。インスピレーションの幅が広がりそうです。