Sure. Here’s some TS which compiles but doesn’t produce a valid React component. It fails if you click the button 5 6 times:
import React, { useState } from "react";
export default function App() {
const [count1, setCount1] = useState(0);
let count2 = NaN, setCount2: React.Dispatch<React.SetStateAction<number>> = _ => {};
if (count1 > 5) {
[count2, setCount2] = useState(0);
}
return (
<div>
<button onClick={() => setCount1((s) => s + 1)}>Count 1: {count1}</button>
<button onClick={() => setCount2((s) => s + 1)}>Count 2: {count2}</button>
</div>
);
}
As you’d expect, eslint does catch this problem if it’s enabled:
Eslint error: React Hook “useState” is called conditionally. React Hooks must be called in the exact same order in every component render. (react-hooks/rules-of-hooks)
Eslint is not a safe type system though. It only scans for specific syntax patterns and it’s fairly easy to fool it.
With this modification eslint is happy as well, but it still fails at runtime:
import React, { useState } from "react";
const sneakyUseState = useState;
// ^---- rebind `useState`
export default function App() {
const [count1, setCount1] = useState(0);
let count2 = NaN, setCount2: React.Dispatch<React.SetStateAction<number>> = _ => {};
if (count1 > 5) {
[count2, setCount2] = sneakyUseState(0);
// ^---- eslint no longer sees this problem
}
return (
<div>
<button onClick={() => setCount1((s) => s + 1)}>Count 1: {count1}</button>
<button onClick={() => setCount2((s) => s + 1)}>Count 2: {count2}</button>
</div>
);
}
Here is the same component in PureScript:
module Main where
import Prelude
import Data.Number (nan)
import React.Basic.DOM as R
import React.Basic.DOM.Events (capture_)
import React.Basic.Hooks (Component, component, useState, (/\))
import React.Basic.Hooks as React
app :: Component {}
app = component "App" \_ -> React.do
count1 /\ setCount1 <- useState 0
count2 /\ setCount2 <-
if count1 > 5 then
useState 0
else
pure $ (0/0) /\ mempty
pure $ R.div_
[ R.button
{ onClick: capture_ $ setCount1 (_ + 1)
, children: [ R.text $ "Count 1: " <> show count1 ]
}
, R.button
{ onClick: capture_ $ setCount2 (_ + 1)
, children: [ R.text $ "Count 2: " <> show count2 ]
}
]
The else
branch fails to compile because it contains different hooks compared to the if..then
branch:
Could not match type
Unit
with type
UseState Int Unit
while trying to match type t2
with type UseState Int (UseState Int Unit)
while solving type class constraint
Type.Equality.TypeEquals (UseState Int Unit)
(UseState Int (UseState Int Unit))
while checking that expression pure
has type t0 -> t1
in value declaration app
where t1 is an unknown type
t0 is an unknown type
t2 is an unknown type
The error makes more sense if we look at the type of useState 0
:
Hook (UseState Int) (Int /\ ((Int -> Int) -> Effect Unit)
-- ^ but that noisy argument at the end is
-- not important, so we'll ignore it:
Hook (UseState Int) _
Hook
is just an alias for the Render
type, so let’s expand it:
Render hooks (UseState Int hooks) _
^ ^
-- note this `hooks` variable
You can read this as "useState
takes the current ‘chain of hooks’ and extends it with UseState Int
". In the error message above, the compiler is trying to match UseState Int (UseState Int Unit)
(the if..then
branch) and UseState Int Unit
(the else
branch). The only way to achieve the same type in the else
branch is to use setState x
where x :: Int
exactly once.
These same protections work for all operations because they all use the same type system. It doesn’t matter whether the hook function is renamed or moved or whether you’re using if/else
, when
, or traverse
.
Here’s an editable version of the TS example: https://codesandbox.io/s/agitated-faraday-p5lb6?file=/src/App.tsx
And the PS example: https://try.purescript.org/?gist=330cc8fcd49ccc66f3265a9af3ba48bf