👏

TypeScript/React18でのContext Objectの型付けについて

2022/06/13に公開
1

こんにちは。最近は奥さんだけでなく型チェッカにも怒られないことを目標にしている者です。
さて、先日 TypeScript(tsc4.7.3)でReact18のContext APIを使おうとして気づいたことがあったので、小ネタですが投稿してみます。

React 18でContext APIを使おうとして気づいた問題

ReactのContext APIを使う際に、グローバルなコンテキストオブジェクトの情報をカスタムフックやカスタムプロバイダのようなwrapper経由で使うことが多いかと思います。フックについては、例えばこんな感じで定義します:

https://github.com/nfunato/lr_6_4_4/blob/main/src/hooks/useColors.ts

ここでの話ではありますが、useContextの引数としてグローバルスコープに置かれたColorContextというコンテキストオブジェクトを参照しています。

React17まで、この コンテキストオブジェクトは、

  const ColorContext = createContext()

のように、無引数でcreateContextを呼び出して生成していましたが、React18のcreateContextはdefaultValueという必須の引数を1つ取るようになりました。それによると、変更の目的は:

defaultValue引数は、コンポーネントがツリー内の上位に対応するプロバイダを持っていない場合のみ使用されます。このようなデフォルト値は、ラップしない単独でのコンポーネントのテストにて役に立ちます。

ということで、テストに便利なようにということですね。

そうして TypeScriptのもとでは、当然にcreateContextの引数は、対応するContext.Providerの必須属性であるvalue属性の値と型が整合していなくてはならないわけです。

React18で初めてContext APIを使おうとしたときに、求めていたwrapperの例をすぐに見つけられなかったので、以前に読んだReactハンズオンラーニングという本の6.4.4節の例(codesandbox)を思い出して、JavaScriptからTypeScriptに書き換えてみることにしました。カスタムフックとカスタムプロバイダの双方を提供するという要望に合っていたからです。
この例におけるカスタムプロバイダColorProviderの定義部分では、Context.Providerコンポーネントのvalue属性に対して { colors, addColor, rateColor, removeColor } というオブジェクトを渡しますが、このオブジェクトの各属性の値は useStateフックの返り値(例えば第2返り値のsetColor関数)への参照情報を含んでいます。つまり、グローバルスコープでは確定しない値への参照情報を含んでいます

実際に書き換えてみると、型チェッカにいろいろと怒られることとなり、その際、

以下のようなグローバルスコープでのコンテキストオブジェクト(ColorContext)の宣言において、defaultValueの値をどうすべきなのか?

ということが私の中で疑問になりました:

  const ColorContext = createContext(defaultValue)

もちろんdefaultValueはテストが主眼ということなので、型の同じ適当なダミーのdefaultValueをあてておけばいいんじゃね...というのは当然に考えられます。しかし、そのようなテストを行わない(or 行えない)状況で、ダミーの記述を増やすのはなんだかなぁという気がします。そして、本番用の値をどこかでColorContextにセットしないといけないという状況は変わりません。
また、試してみたところ、

  const ColorContext = createContext({ colors: "#ff0000" })

のようにサブセットの属性を持つオブジェクトを記述しても型チェッカに怒られます。


整理すると、以下を全て満たすことは可能か? ということが問題です:

  • 冒頭のuseColorsのようなカスタムフックを定義する際に参照しやすいように、グローバルスコープでコンテクストオブジェクトの変数(本稿の例ではColorContext)を宣言したい

  • 一方で、グローバルでないスコープでしか確定しない値をコンテクストオブジェクトの生成に用いたい

  • コンテクストオブジェクトの変数の型、createContextの返す値の型、Context.Providerがvalue属性に基づいて与えるコンテクストの型を整合させて、型チェッカに怒られないようにしたい

  • やや主観的になりますが、使わないダミーの値(createContextのdefaultValue等)をセットするような余計な記述をできるだけ省きたい

結論

以下のように、型制約は付けるが初期化を行わないlet宣言を用いて、グローバルスコープで変数ColorContextを宣言します:

  export let ColorContext : React.Context<ColorContextTyp>;

そして、必要な値が確定したところでcreateContextを使って生成したColorContextの本番用の値を設定するようにします。ここで { colors, ..., removeColor } の型は、ColorContextTypです:

  ColorContext = createContext({ colors, addColor, rateColor, removeColor })

このように、let宣言に型は付けるのですが初期化は行いません。let宣言の段階ではColorContextの値はundefinedということになる筈ですが、使わなければ問題になるわけではなく、型チェッカによる怒られも発生しないようです。
上記のコードはいずれもこのファイルに含まれています。

ここで述べた結論は後から考えると当たり前という気がするわけですが、すんなりこの組み合わせに思い至りませんでした。createContextで生成したオブジェクトをグローバルスコープで初期化代入することに思い込みがあったためと思います。

おわりに

実際には、最初から問題が整理された形であったわけではなく、Reactハンズオンラーニングの6.4.4節のコードをTypeScript化して、できるだけ余計な記述をせずに型チェッカを通したい、ということがそもそもの課題でした。

問題の整理を試みたのは、若干模索してみて折り合いを付けた後のことなので、他に異なる捉え方があるかもしれないと思い本稿にまとめてみました。

型チェッカを通す書き方にもいろいろある筈で、保守性を優先した結果、スッキリした分かり易さとは若干異なる選択になる場合もあるでしょう。TypeScriptの型システムと付き合うには、OCamlやF#あたりとは少し異なるノウハウが必要なんだろなと感じる今日この頃です。

参考

どう検索したか忘れてしまったのですが \(^^)/ createContextの引数の型チェックで引っかかる状況は散見されます。例えば、以下の2つの記事はこのような状況に関連しています:

ざっと眺めただけで孫参照などは読んでいませんが、ここで挙げた手法との直接の関係は無いと思い、リンクを挙げるに留めます。

letで宣言して少し遅れて代入するような方法は嫌じゃ〜、ということであれば他に方法がないわけではない気もします(例えば、グローバルスコープで確定しない値が確定すればwrapしてcontext objectの一部に代入するとか、同様の趣旨でclassを利用できないかとか)が、現時点では追求していません。他に公知の良い作法があれば教えていただけると幸いです。

Context APIの機能は、Lisp族の機能でいえばCLのspecial variableやSchemeのparameterizeに近いわけですが、Reactとセットで使われるために規定されている辺りが、本当に理解できているのか自分では分かっていません。まぁぼちぼちと...

Discussion

yuki2006yuki2006

こんにちは、

同じく型の良い方法はないかとこの記事を参考にしていて、なるほど思ったのですが、問題が起こることがわかりました。

結論から言うと contextを使っているコンポーネントが 再mount(初期化)されてしまうことがわかりました。

おそらく

ColorContext = createContext({ colors, addColor, rateColor, removeColor })

をしたときに、別のContext扱いになってしまい、それを使っているコンポーネントが再mountされるんじゃないかと推測しました。
(状態をコンポーネントに持ってなかったらおかしいことが起こらなかったんじゃないかと思います。)

下のコードはuseStateでクリックした回数を保持するものです。

https://github.com/yuki2006/lr_6_4_4/commit/610f8782a5dbb44f241266413fe459d97c66ce4a

レーティング部分をクリックするとカウントアップするつもりなのですが更新されません
(リセットされます)

グローバルで1度createContextをすることで正しい挙動になりました。
https://github.com/yuki2006/lr_6_4_4/commit/3aaf267e02dfdff8ed7380ef16cc3c1408a59bdd

確かに型的に良い方法があればよいのですが...