generated at
purescript-react-basic-hooksはIxMonadでHooksの順序を規定する
React Hooksには「毎回のrenderingにて、呼び出されるhookの順序は同じでなければならない」というルールがある
React HooksのPureScript実装であるpurescript-react-basic-hooksでは、その順序のルールをIndexedモナドを用いて型レベルで規定している
これによって、順序の正しさをコンパイラが静的に保証してくれる
元のReact HooksではTypeScriptを使えどHooksの順序は静的に検証はできない
eslint-plugin-react-hooksのようなLinterを使って警告を出すなどして対応する


結論
IxMonadなどの型のおかけで
全てのReactHooksのルールの違反を防ぐわけではないが、
少なくとも、「ReactHooksのルールの違反による実行時エラー」は防げる
ということがわかる



IxMonadについて軽くおさらい
表記が揺れているが、 IxMonad と、Indexedモナドは全く同じものを指しているmrsekut
IxMonadは、通常のMonadの一般化である
通常のMonadと違って、monadic computationで出力の型を変えることができる
通常のMonadの定義はこんな感じ
hs
class Monad m where return :: a -> m a (>>=) :: m a -> (a -> m b) -> m b
IxMonadの定義はこんな感じ
hs
class IxMonad m where return :: a -> m i i a (>>=) :: m i j a -> (a -> m j k b) -> m i k b
例えば (>>=) の第1引数の型が、 m a から m i j a のように変わっている
monadic computationの実行前の i と、実行後の j があることで、順序を規定できる
何が嬉しいのか?の一例が、このReact Hooksの順序規定である



Effect stack
purescript-react-basic-hooks内では、「そのComponentが、1度のrenderingで使用されるHooksたち」は型のstackで表現されている
こういう型をdocs内では、「stack of effects」と呼んでいる ref
ここでは「Effect stack」と呼ぶmrsekut
モナド変換子の文脈の「Monad stack」と同じイメージmrsekut
例えば以下のようなcustom hooksを見てみる
purs(hs)
useHoge :: ∀ a. Render a (UseState Int (UseEffect Unit a)) Int useHoge = React.do useEffectAlways do -- UseEffect pure mempty cnt /\ setCnt <- useState 0 -- UseState pure cnt
内容はどうでもいいので、hooksの呼び出し順序と、型にのみ着目するmrsekut
まず最初に useEffectAlways hookを呼んで、次に useState hookを呼んでいる
この場合、Effect stackは UseState Int (UseEffect Unit a) となる
この型は、上記コードの1行目を見ても分かる通り、 useHoge 自体の型にも現れる
この型シグネチャのまま、 useEffectAlways useState の位置を入れ替えると型エラーになる
このコードを少し観察すると以下のことがわかる
useHoge は、 Render IxMonadである(後述)
useHoge は、 Render IxMonad用のdo式( React.do )を使って定義されている
各Hooksは、monadic computationである
他の例も見てみる
purs(hs)
usePiyo :: ∀ a. Render a (UseRef Int (UseState Int (UseEffect Unit (UseRef Int a)))) Int usePiyo = React.do rendersRef <- useRef 1 -- UseRef useEffectAlways do -- UseEffect pure mempty cnt /\ setCnt <- useState 0 -- UseState rendersRef <- useRef 1 -- UseRef pure cnt
内容はどうでもいいので、hooksの呼び出し順序と、型にのみ着目するmrsekut
useRef useEffectAlways useState useRef という順序でhooksを呼んでいる
この場合、Effect stackは UseRef Int (UseState Int (UseEffect Unit (UseRef Int a))) となる
見たら分かる通り、呼び出されたhookの順番で上へ上へとstackが積まれていることがわかる



Render 型の定義 ref
purs(hs)
newtype Render :: Type -> Type -> Type -> Type newtype Render x y a = Render (Effect a)
React.Basic.Hooks.Internalで定義されている
2つのindexとしての幽霊型 x , y を持っている
x が、この Render 開始前のhooksのEffect stack
y が、この Render 終了後のhooksのEffect stack
Render の指しているscopeに注意しないと頭がバグるmrsekut
個々のhooksもRender型で定義するし、
component を作る際にも Render 型が使われる
この場合の Render は、「componentのrendering」と同じものを指す
ReactHooksのルールでは、この場合の x y が一致していることを求められる
Render 型は、Indexedモナドのinstanceである ref
purs(hs)
instance IxApplicative Render where ipure a = Render (pure a) instance IxBind Render where ibind (Render m) f = Render (Prelude.bind m \a -> case f a of Render b -> b) instance IxMonad Render
Render 型は、通常のMonadのinstanceにもなっている ref
実装自体は全く同じ
Type.Equality TypeEquals を使って、「 x y が等しい」という制約が与えられている
purs(hs)
instance TypeEquals x y => Applicative (Render x y) where pure a = Render (pure a) instance TypeEquals x y => Bind (Render x y) where bind (Render m) f = Render (Prelude.bind m \a -> case f a of Render b -> b) instance TypeEquals x y => Monad (Render x y)
これによって、rendringの前回と今回とで呼び出すhooksの種類と順序が同じであることを静的に検証できる


Hook ref
useState などの個々のhookは、 Hook 型を使って定義される
Hook 型は、 Render 型のエイリアスとして定義されている
purs(hs)
type Hook (newHook :: Type -> Type) a = forall hooks. Render hooks (newHook hooks) a
hooks 型は、このhookを実行する直前のEffect stack
newHook は、 hooks を引数にとって、 newHook hooks を返す型
今回のhookを、元のEffect stackの先頭に追加する
だから最初に呼ばれたhookがstackの一番下に来るんやねmrsekut
Render がIxMonadのinstanceになっていないと、この「積んでいく操作」ができない
つまり、通常のMonadでは、この「積んでいく操作」ができない
なぜなら、monadic computationの実行前後で型が変わっているから
hooks 型から、 newHook hooks 型に変わっている
具体例を挙げると例えば、 UseEffect Unit Int から、 UseState Int (UseEffect Unit Int) に変わっている



個々のHookの型はこんな感じで定義されている ref ref
purs(hs)
useState :: forall state. state -> Hook (UseState state) (state /\ ((state -> state) -> Effect Unit))
purs(hs)
useEffect :: forall deps. Eq deps => deps -> Effect (Effect Unit) -> Hook (UseEffect deps) Unit
これらがIxMonad Render に対するmethodに相当する
StateモナドをIndexed Stateモナドにしていくでの iput , iget と同じ立ち位置mrsekut
対応
\StateモナドIxStateモナドRender(今回の話)
State s aState si so aRender x y a
methodput, getiput, igetuseState, useEffect, etc.



実際にReactHooksのルールを違反してみて型エラーが得られるかを見てみる
型エラーになる例
Effect stackが異なれば必然的に型エラーを得られる
こういうcustom hookを作って
purs(hs)
-- Effect stackは、UseEffect Unit (UseRef Int (UseState Int a)) useHoge = React.do cnt /\ setCnt <- useState 0 rendersRef <- useRef 1 useEffectAlways do pure mempty pure $ cnt /\ setCnt
purs(hs)
cnt /\ setCnt <- if odd x then useState 10 else useHoge -- type error!
「ifの中でhooksを使ってはいけない」というルールを破っている例
理想的な挙動にはならないが型エラーも出ない例
purs(hs)
cmt /\ setCnt <- if x > 10 then useState 10 else useState 5
残念ながらこれは型エラーにならない
何故なら、両方とも UseState Int をEffect stackに積むので、型レベルで見るとEffect stackは同じものになるから
しかし、これはReactHooksのルール#61500fd719827000005ddfa6にも書いたとおり、「ifの中でhooksを使ってはいけない」というルールを破ってはいるが、そもそも実行時エラーは生じない
「人間の想像した挙動と異なる」だけで、「これが仕様」と思えば、型エラーを出さないほうがある意味正解なのかもしれないmrsekut


例えばこれでもエラーになる
custom hooksの型によって、内部で呼び出すhooksの順番と個数が規定されるので型レベルプログラミング的になる
purs(hs)
useHoge :: ∀ a. Render a (UseEffect Unit (UseRef Int (UseState Int a))) (Int /\ ((Int -> Int) -> Effect Unit)) useHoge = React.do cnt /\ setCnt <- useState 0 -- rendersRef <- useRef 1 useEffectAlways do pure mempty pure $ cnt /\ setCnt
でも、実際Custom Hook作る時に、
「内部で使うHooksの個数と順序を先に決め」てから定義するとは思えないので、
後付けで型を指定することになるんだろうなmrsekut


つまり、IxMonadなどの型のおかけで
全てのReactHooksのルールの違反を防ぐわけではないが、
少なくとも、「ReactHooksのルールの違反による実行時エラー」は防げる
ということがわかる


IxMonadの関係のない余談だが、このエラーとかは、そもそも書けない
purs(hs)
mkCounter = do component "Counter" \initialValue -> React.do count /\ setCount <- useState initialValue setCount (_ + 2) -- type error!! pure $ R.button { .. }
React.do の中では Render モナドしか使えないが、 setCount の返り値は Effect なので。
やったねmrsekut


これも余談だが、IxMonadは、通常のdo記法が機能しない
通常のdo記法はMonadの bind の糖衣構文であり、IxMonadはMonadの一般化なのでその枠を超えているため。
そこで、新しくRender IxMonad用の bind を使うようにしている
hsで同じことをする場合は、RebindableSyntax拡張が必要になるがpursでは不要っぽい
代わりに(?)Discard型クラスなどがある
purs(hs)
import Prelude hiding (bind, discard) import Prelude (bind) as Prelude import React.Basic.Hooks.Internal (bind)
binddiscardを定義するなどする
purs(hs)
bind :: forall a b x y z m. IxBind m => m x y a -> (a -> m y z b) -> m x z b bind = ibind discard :: forall a b x y z m. IxBind m => m x y a -> (a -> m y z b) -> m x z b discard = ibind
いまいちこの辺わかっていないmrsekut #??
これと同じようなことをしている




この記事は、PureScript Advent Calendar 2021 12日目の投稿です

>codes


HookError1.purs(hs)
module Hoge where import Prelude import Data.Int (odd) import Data.Tuple.Nested (type (/\)) import Effect (Effect) import React.Basic.DOM as R import React.Basic.Events (handler_) import React.Basic.Hooks (Component, Render, UseEffect, UseState, component, useEffectAlways, useState, (/\)) import React.Basic.Hooks as React mkCounter :: Component Int mkCounter = do component "Counter" \initialValue -> React.do x /\ setX <- useState initialValue cnt /\ setCnt <- if odd x then useState 10 else useHoge pure $ R.button { onClick: handler_ do setX (_ + 1) , children: [ R.text $ "Increment: " <> show x ] } useHoge :: ∀ a. Render a (UseState Int (UseEffect Unit a)) (Int /\ ((Int -> Int) -> Effect Unit)) useHoge = React.do useEffectAlways do pure mempty cnt /\ setCnt <- useState 0 pure $ cnt /\ setCnt