🔗

useEffect()内でsetState()するのを減らすテク

2021/11/07に公開

コンポーネント上の useEffect() (or useLayoutEffect()) で複雑なこと、特に再レンダリングを発生させるsetState()等をすると、deps配列(第2引数)の指定方法などによっては、おかしな挙動を起こしうるのでなるべく避けたいです。何より、テストが面倒なプレゼンテーションロジックは、常にシンプルに保つ方がメンテナンスが容易になります。

追記: これはStateの更新がEvent(setState())を発生させ、さらなるState更新を生むことで、データの流れが複雑になっているというところが本質かなと思います。ReduxじゃなくてもUnidirectional Data Flow(単方向データフロー)は普遍的な概念として有効なはずです。
Unidirectional Data Flowについては画像作りをサボってしまったのでこの記事あたりを参照してくださいませ。

複数の useEffect() の順番や無限ループなどにハマる(考えないと理解できないなど)くらいの状態になるようであれば、その useEffect() を他のもので置き換えられないか検討するチャンスです。

TL;DR: const [state, setState] = useOverrideValue(defaultState, [dep1, dep2, ...]) のようなhookを作って1年くらい使ってみた。けどだいたいはもっとシンプルに書けるはずだった。

useEffect() から setState() を呼ぶ代わりに

主だったケースは下記のようなものが挙げられると思います(他にあればご指摘願います🙏)

  • 非同期処理の結果を描画したい
    • HTTPリクエストならuseSWR()React Query、素のPromiseなら(試していないけど)usePromise()などを使えば、副作用を useEffect() ではないhooksの中に閉じ込められます
  • setTimeout() 後にフラグを落とすなどしたい
    • (中身がシンプルな場合は、 useEffect() のままでも良いのでは?と思います)
  • propsや他のhooksの返り値に応じて setState() したい → この記事で取り上げる内容です

他のhooksの結果やpropsに依存した値である、かつ、 setState() もほしい

このケースに対応するために、 useOverrideValue() というhookを作りました。
https://gist.github.com/ypresto/4d78f7d9d30a46c2d44937a79ee84cef

const [state, setState] = useOverrideValue(defaultState, [dep1, dep2, ...])

useState() のように使いますが、depのどれかが更新された時に、defaultStateに戻るようになっています。

他に、何かが条件を満たした時(flagがtrue)だけカウントがひとつ進む useFlipCount(flag) を用意して、組合せでクリアできるようにしました。

業務で作っているアプリで、導入してから1年ほどですが、下記のようなケースで使っていました。ただし再度使用箇所を調べたところ、多くのケースでは必ずしもこのhookを使わずに解決できそうなことがわかりました。実際に使われていた箇所の例を紹介していきます。

Dialog状態のリセット (material-uiなど)

多くのUIライブラリでDialogはアニメーションで消えるかと思いますが、入力欄があるDialogが完全に消えるまでにクリアしてしまうと、一瞬クリア状態が見えてしまい不自然という問題があります。 open: true になった瞬間か、完全に閉じた後にリセットすることで回避できますが、Dialogのopenを制御するのはほぼ親(祖先)コンポーネントなので、子からopenの瞬間をイベントで得ることはできません。

当初、DialogにonSubmitが生えた便利コンポーネントを使ってたためDialogの外側で状態を管理しており、 useOverrideValue() を使ってこのように対処していました。

function FooDialog({ open, onClose, defaultState }) {
  const openCount = useFlipCount(open) // openがtrueに変わるたびにカウントが進むhookです
  const [dialogState, setDialogState] = useOverrideValue(defaultState, [openCount])
  const [submitting, setSubmitting] = useState(false)
  
  const handleClose = () => {
    if (submitting) return
    onClose()
  }
  
  return (
    <Dialog open={open} onClose={handleClose} disableEscapeKeyDown={submitting}>
      <DialogBody ... />
    </Dialog>
  )
}

取りうる解決策として下記が挙げられます。

  • onExited 等、Dialogが完全に見えなくなったタイミングでリセット
    • 親から受け取るdefaultStateが変わる場合に元の値のままになってしまいます
    • transition用のcallbackなので、実行されることが保証されない気がしております(情報あればください)
  • useLayoutEffect() + useRef() (最後のopen状態保存用)や、 useOverrideValue() でopenに変わった時にリセット
    • 悪くはないけど、↓が良いなら複雑なことをしないので、それがベターかなと感じました
  • <DialogBody>の方にsubmitting以外の状態を持たせて、unmountで自動クリアさせる
    • (少なくともmaterial-uiの場合)Dialogが完全に消えると中身がunmountされ、hooksの状態が確実に消去されます
    • 閉じてる「途中」に再度openになるとクリアされないかもしれないです?が、material-uiの場合は後ろの要素を押せないのでひとまず大丈夫そうです
      • 追記: 心配であれば const openCount = useFlipCount(open); <DialogBody key={openCount}>...</DialogBody> のようにすれば、閉じきってないままopenになってもクリアできます

(当初 useOverrideValue() はこの対応のために作りました)

Optimistic Update

サーバ側にある何かを更新した後、再度フェッチが完了するまでの一時表示を行う場合です。
https://qiita.com/devneko/items/a636b81be76b9e2137f2

// itemが更新されたら、再描画する。それまではsetState()された値を表示する
const [state, setState] = useOverrideValue(item.state, [item])

これは非同期処理の一種で useSWR() などのクライアントがビルトインで対応してくれている部分なので、可能ならそちらを使う方が良さそうです。

URLのクエリパラメータやハッシュに応じて表示を変えたい場合

const router = useRouter() // next.jsの機能です
const query = router.query.query // (注:実際はarrayが入ることもあります)
const [page, setPage] = useOverrideValue(0, [query]) // queryが変わったら0に戻ります

クエリパラメータの変更とともに setPage(0) する手段もありますが、関連するstateが増えてくると setFoo() の呼び出しをいくつも書くことになってしまい、onChangeなどのコールバック関数が膨れてしまいます。

この例の場合、 pageも同様にqueryに入れてしまった方が (ページ遷移しても最後に表示されていた位置に戻るため)ユーザー体験に寄与するケースで、useOverrideValue() でなくても良かったと言えそうです。

他にも、例えばNext.jsの場合は /items/[id] のようなルートの場合にidだけ変わってもuseState()がクリアされない ので、それをクリアしたいケースがあります。これは <div key={item.id}>...</div> のようにkeyを指定するとkeyが変わった時に破棄・再生成できるので、その方が良さそうです。

値が変わった時に、処理を一回だけ実行したい

そもそも useEffect() ではうまく対応できないケースも挙げます。
router.push() するなど、クリーンアップできず複数回呼びたくない場合です。

React 18では useEffect() の中身が、deps配列の変更がなくても、複数回呼ばれることがあるので、べき等でない操作は(StrictModeが有効な場合は特に) useEffect() に書くことはできません。今のところは、 onClick() などpropsやstateが変わるトリガーの部分に書くか、 useRef() でフラグ管理するのが良さそうです。

React 18での useEffect() については、詳しくは下記などを参照ください。
https://blog.koba04.com/post/2021/06/16/effects-in-react-v18

追記:現時点ではおおむねStrict Mode有効時のみ複数回呼ばれると考えて良さそうですが、React開発チームがあえてこのような変更を加えるということは、今後1回のみ呼ばれるという前提を捨ててほしいというメッセージだと理解しています。

まとめ

useOverrideValue() を作った当初は useEffect() が消せて便利だとよろんでいたのですが、上記の通り(useEffect()すら)使わないで済むケースがかなり多いことがわかってしまいました。

それでもどうしても useEffect() が消せない時に、 useOverrideValue() がお役にたてば幸いです。

Discussion