RSS feed iconGitHub logo

fp-ts with React hooks

2023-05-106 minutes readfp-ts, React

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 Eithers and Options 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 Options and Eithers (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)
}

Rust playground

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.