purescript-react-basic-hooksはIxMonadでHooksの順序を規定する
React Hooksには「毎回のrenderingにて、呼び出されるhookの順序は同じでなければならない」というルールがある これによって、順序の正しさをコンパイラが静的に保証してくれる
元のReact HooksではTypeScriptを使えどHooksの順序は静的に検証はできない
結論
IxMonadなどの型のおかけで
ということがわかる
IxMonadについて軽くおさらい
表記が揺れているが、IxMonadと、Indexedモナドは全く同じものを指しているmrsekut.icon IxMonadは、通常のMonadの一般化である
通常のMonadと違って、monadic computationで出力の型を変えることができる
通常のMonadの定義はこんな感じ
code:hs
class Monad m where
return :: a -> m a
(>>=) :: m a -> (a -> m b) -> m b
IxMonadの定義はこんな感じ
code: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
こういう型をdocs内では、「stack of effects」と呼んでいる ref ここでは「Effect stack」と呼ぶmrsekut.icon
モナド変換子の文脈の「Monad stack」と同じイメージmrsekut.icon
例えば以下のようなcustom hooksを見てみる
code: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.icon
まず最初にuseEffectAlwayshookを呼んで、次にuseStatehookを呼んでいる
この場合、Effect stackはUseState Int (UseEffect Unit a)となる
この型は、上記コードの1行目を見ても分かる通り、useHoge自体の型にも現れる
この型シグネチャのまま、useEffectAlwaysとuseStateの位置を入れ替えると型エラーになる
このコードを少し観察すると以下のことがわかる
useHogeは、RenderIxMonadである(後述)
useHogeは、RenderIxMonad用のdo式(React.do)を使って定義されている
各Hooksは、monadic computationである
他の例も見てみる
code: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.icon
useRef→useEffectAlways→useState→useRefという順序でhooksを呼んでいる
この場合、Effect stackはUseRef Int (UseState Int (UseEffect Unit (UseRef Int a)))となる
見たら分かる通り、呼び出されたhookの順番で上へ上へとstackが積まれていることがわかる
https://gyazo.com/df894f248ef941a8df45b9ab1cab0019
code:purs(hs)
newtype Render :: Type -> Type -> Type -> Type
newtype Render x y a = Render (Effect a)
2つのindexとしての幽霊型x,yを持っている
xが、このRender開始前のhooksのEffect stack
yが、このRender終了後のhooksのEffect stack
Renderの指しているscopeに注意しないと頭がバグるmrsekut.icon
個々のhooksもRender型で定義するし、
componentを作る際にもRender型が使われる
この場合のRenderは、「componentのrendering」と同じものを指す
code: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 実装自体は全く同じ
code: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の種類と順序が同じであることを静的に検証できる
useStateなどの個々のhookは、Hook型を使って定義される
Hook型は、Render型のエイリアスとして定義されている
code: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.icon
RenderがIxMonadのinstanceになっていないと、この「積んでいく操作」ができない
つまり、通常のMonadでは、この「積んでいく操作」ができない
なぜなら、monadic computationの実行前後で型が変わっているから
hooks型から、newHook hooks型に変わっている
具体例を挙げると例えば、UseEffect Unit Intから、UseState Int (UseEffect Unit Int)に変わっている
個々のHookの型はこんな感じで定義されている ref ref code:purs(hs)
useState :: forall state.
state
-> Hook (UseState state) (state /\ ((state -> state) -> Effect Unit))
code:purs(hs)
useEffect :: forall deps.
Eq deps
=> deps
-> Effect (Effect Unit)
-> Hook (UseEffect deps) Unit
これらがIxMonadRenderに対するmethodに相当する
table:対応
\ Stateモナド IxStateモナド Render(今回の話)
型 State s a State si so a Render x y a
method put, get iput, iget useState, useEffect, etc.
型エラーになる例
Effect stackが異なれば必然的に型エラーを得られる
こういうcustom hookを作って
code: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
code:purs(hs)
cnt /\ setCnt <- if odd x
then useState 10
else useHoge -- type error!
「ifの中でhooksを使ってはいけない」というルールを破っている例
理想的な挙動にはならないが型エラーも出ない例
code:purs(hs)
cmt /\ setCnt <- if x > 10
then useState 10
else useState 5
残念ながらこれは型エラーにならない
何故なら、両方ともUseState IntをEffect stackに積むので、型レベルで見るとEffect stackは同じものになるから
「人間の想像した挙動と異なる」だけで、「これが仕様」と思えば、型エラーを出さないほうがある意味正解なのかもしれないmrsekut.icon
例えばこれでもエラーになる
custom hooksの型によって、内部で呼び出すhooksの順番と個数が規定されるので型レベルプログラミング的になる
code: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.icon
つまり、IxMonadなどの型のおかけで
ということがわかる
IxMonadの関係のない余談だが、このエラーとかは、そもそも書けない code: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.icon
これも余談だが、IxMonadは、通常のdo記法が機能しない
通常のdo記法はMonadのbindの糖衣構文であり、IxMonadはMonadの一般化なのでその枠を超えているため。
そこで、新しくRender IxMonad用のbindを使うようにしている
hsで同じことをする場合は、RebindableSyntax拡張が必要になるがpursでは不要っぽい
code:purs(hs)
import Prelude hiding (bind, discard)
import Prelude (bind) as Prelude
import React.Basic.Hooks.Internal (bind)
code: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.icon #?? codes
code: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:
}
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