I love using fp-ts for its expressiveness
when using the Option
and Either
containers. I like the Option
container in particular because it allows
expressing nullability in a type-safe way
in legacy TypeScript projects that have strictNullChecks
disabled in their
tsconfig.json
.
Working with these containers is usually pretty cheap. Determining whether an
Either
is Left
or Right
or whether an Option
is Some
or None
is
usually one if
away (although you probably want to use other methods instead
of unwrapping these containers right away).
However, one difficult part is the lack of referential stability of these fp-ts container values. This is particularly problematic with React hooks that use a dependency array and strict equality to decide whether the dependencies changed.
Consider the following component (TypeScript playground):
import { option } from "fp-ts";
import { pipe } from "fp-ts/function";
import React, { useState, useContext, useEffect, createContext } from "react";
const UsernameContext = createContext<string | null>(null);
function UserProfilePicture() {
const maybeUsername = option.fromNullable(useContext(UsernameContext));
const [profilePictureUrl, setProfilePictureUrl] = useState<
option.Option<string>
>(option.none);
useEffect(() => {
if (option.isNone(maybeUsername)) {
return;
}
const abortController = new AbortController();
fetch(`/user/${maybeUsername.value}`, {
signal: abortController.signal,
})
.then((res) => res.json())
.then((user) => {
setProfilePictureUrl(user.picture_url);
});
return () => abortController.abort();
}, [maybeUsername]);
return pipe(
option.some((username: string) => (url: string) => (
<img src={url} alt={`${username} profile picture`} />
)),
option.ap(maybeUsername),
option.ap(profilePictureUrl),
option.getOrElse(() => null)
);
}
It seems well, except for the fact that it will keep rerunning the effect and
sending requests on each rerender. The culprit? option.fromNullable
which
creates a new Some<A>
object each time it is called with a defined value.
This, in turn, is fed into the useEffect
dependency array, which makes it run
the effect again, even if the inner value inside the Option
is still the same.
This is a risk stemming from creating new Either
s and Option
s on the fly.
Their references are not stable. They will create new objects each time.
Workarounds
Not all is lost. While we wait for the auto-memoizing React compiler, we can still use some workarounds for this problem.
Memoize the call to option.fromNullable
If we change
const maybeUsername = option.fromNullable(useContext(UsernameContext));
to
const unsafeUsername = useContext(UsernameContext);
const maybeUsername = useMemo(
() => option.fromNullable(unsafeUsername),
[unsafeUsername]
);
then the Option
itself will remain the same object in memory as long as the
unsafeUsername
remains the same.
This works, but has a downside of leaking the unsafeUsername
variable in the
current scope.
Use specialized fp-ts React hooks
fp-ts-react-stable-hooks
is a library exposing fp-ts-aware React hooks. One of them is useStableEffect
for which we define an Eq
(equality rules) that will be used to compare the dependency arrays:
useStableEffect(
() => {
// The body of the effect is unchanged
// ...
},
[maybeUsername],
eq.tuple(option.getEq(eq.eqStrict))
);
The constructed Eq
will compare the maybeUsername
from dependency arrays and
determine if they meaningfully differ (one is Some
and the other is None
, or
both are Some
with different inner values).
Automatically creating an fp-ts-aware Eq
Looking at fp-ts-react-stable-hooks
I had an idea. Hand-writing these Eq
implementations is surely error-prone and cumbersome. There must be a way to
automatically create an Eq
implementation based on the dependency array. It
would meaningfully compare Option
s and Either
s (compare their contents) and
use strict equality comparisons for other values.
I created
optionEitherAwareEq
that does exactly that. While it seems to work well
(the tests
pass), it has some downsides:
- it inspects the dependency array to decide which
Eq
to use for each item. This could be costly, especially since this is done on each render. - it assumes the order of elements in the dependency array is always the same.
I decided not to publish this library, since it could be harmful to the premise of memoization of these values. It is sound, meaning it will correctly meaningfully compare dependency arrays, but it may be slow. Use it at your own risk.
Rant: JavaScript lacks built-in meaningful equality checks
In Rust, there is
an Eq
trait that is a core
concept of the language. Each type can decide how it wants to be compared with
other types (and itself) by implementing the PartialEq
trait.
enum MyOption<T> {
Some(T),
None,
}
impl<T> PartialEq for MyOption<T>
where
T: PartialEq,
{
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(MyOption::None, MyOption::None) => true,
(MyOption::Some(a), MyOption::Some(b)) => a == b,
_ => false,
}
}
}
impl<T: Eq> Eq for MyOption<T>{}
fn main() {
let a = MyOption::Some(1234);
let b = MyOption::Some(1234);
println!("{:?}", a == b)
}
There is no concept like Eq
in JavaScript. Types like Option
are simple
objects which do not have a notion of equality associated with them. If a
library (like React) receives an unknown value, the best it can do to compare it
with some other value is to use ===
(strict equality) or
Object.is
(which it does).
This is unfortunate. I do not see an easy way to solve this problem without a fundamental JavaScript rework to be more aware of types.
Conclusion
Constructing fp-ts containers like Option
and Either
on the fly in the
render cycle poses risks when these are used in dependency arrays. These values
need to be stable so effects do not re-execute after each rerender.