きみはねこみたいなにゃんにゃんなまほう

ねこもスクリプトをかくなり

React のテストは react-dom/test-utils から @testing-library/react に進むと理解しやすそう

lightbulbcat.hatenablog.com

の続きです。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つのライブラリです。現時点の理解を書き添えてみました。

セットアップ

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 によるとモダンブラウザなら普通に使えるようです。

使い方は AbortControllernew してメンバの signalfetch に仕込むと 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...
  )
}

もう少し簡潔に書けると思ったのですが、とりあえずこんな感じです。 controllerfetch が終了したかどうかも取得できれば 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 を扱う簡単なアプリを作成してみました。

最終的にこんな感じに表示されます。

f:id:lightbulbcat:20190802031041p:plain

動作するコードは 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 に渡してマークアップを外した文字列を得る
    • それを CSStext-overflow: ellipsis で省略表示に
    • もしくは JavaScript 側で substring するのでも可能
  • 本文: 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 の評価タイミングが分からないので泣きながら試す

useEffectuseState を組み合わせた 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>
  )
}

関数コンポーネント AppuseMyHook を利用しています。useMyHook の中では useStateuseEffect を利用しており 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_componentWillMountUNSAFE_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 を更新するための useEffects1 の更新に対して実行されるように追加しました。全体の更新のトリガーはマウント時に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} s1s2 も更新された値を同時に得る(タイミングが謎)
-[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 }
}

s1s2useEffect の定義の間に 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 を組んで何とか実装してみます。

経緯としては以下の通りです。

  1. formik-persist を使ったけどページ遷移しても遷移前のフォームの入力値が残り続ける
  2. せっかくなので localStorage を使って自前で処理を書いてみよう
  3. react-useuseLocalStorage を使ってみたけど key が変更されても値が切り替わらないので結局同じ問題が発生する
  4. 好みの挙動になるように自前で 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 が変わってフォームの内容が遷移後のページのものに更新されるように
  • validateonChange として転用
    • 本当はこんなことやりたくないのですが...

残念なポイントとしては、上記のダーティハックはもちろんですが、せっかく Formik が State を管理してくれているのに、それをわざわざ親コンポーネントに引っ張り上げて二重管理をしてしまっている所です。 initialValues が実質 Controlled Component の value みたいな扱いになってしまっているので、ちょっと気持ち悪いです。 React でコンポーネントを組むときに、どうしようもない場合によく現れるパターンな気がしますが、スッキリしないですね。

とりあえず、一応動いたので今回は良しとします。気が向いたら formik-persist のコードも見ながら他にいい方法がないか調べてみます。

追記

以下のように FormikPersist 双方に 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 にアクセスできるんだなっていう良い例ですね。インスピレーションの幅が広がりそうです。