Functional Concepts in React Suspense and Hooks

Functional Concepts in React Suspense and Hooks

三月 06, 2022

React 18 Suspense

I believe all of us web developers have heard about Suspense in React since it was introduced by React team in JSConf 2018. In short, there are two key points in Suspense:

  1. Async Component
  2. Data Fetching

The first feature has been supported for several years and widely used in many projects. But by contrast, data fetching is still not released (now in beta). Let’s see an example of data fetching in React official document:

Suspense for Data Fetching (Experimental)

function ProfileTimeline() {
  // Try to read posts, although they might not have loaded yet
  const posts = resource.posts.read();
  console.log("won't execute until data is ready")
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.text}</li>
      ))}
    </ul>
  );
}

It’s similar to libraries like react-query or swr.

function ProfileTimeline() {
  // If posts are not loaded, it will return `isLoading: true`
  const posts = useQuery(/* arguments */);
  // posts: { isLoading: true, data: null }
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.text}</li>
      ))}
    </ul>
  );
}

But the key difference is that the code below suspense data fetching won’t execute until data is ready. But react-query or swr doesn’t block the function execution, and just returns the status that it’s fetching. The former one is simpler to understand —— it’s just a function, we get posts from resource.posts.read() and render elements. However, in the later one we need to remember there are different state and return elements for each one. And if I trigger some actions, the state may change then cause re-rendering.

Colorful Function

Let’s take a look at suspense data fetching again, it’s like magic —— why a sync function can fetch a delayed value and return synchronously? In normal circumstances, we can only get a delayed value in an async function, waiting for promises, or something like that. It’s a basic concept that statements in a JavaScript synchronous function must be executed immediately after their previous statement.

function getValue<T>(x: T) {
  return x;
}

function synchronize() {
  console.info(getValue(1));
  // nothing can interrupt here
  console.info(getValue(2));
}

function getDelayedValue<T>(x: T) {
  return new Promise(resolve => setTimeout(resolve, 1000))
}

async function asynchronize() {
  console.info(await getDelayedValue(1));
  // may be interrupted
  console.info(await getDelayedValue(2));
}

But in suspense fetching, a synchronous statement is waiting for a delayed value. Is there a synchronous function that returns a delayed value?

async function fetchDelayedPosts(): Promise<Post[]>;

function getPost(): Post[] {
  // FIXME: implement it. of course you can't use busy waiting
  const posts = fetchDelayedPosts();
  return posts;
}

In one word, there isn’t such a function. An async function is “colorful”, that is, an async function can only be called by another async function (imprecise, but you can’t get rid of promise). It’s inconvenient because we should refactor every function involved to async if we want to use an async function. Obviously, we don’t want to use “async” in those React components since it’s difficult to write, understand, and inefficient.

Algebraic Effects

What is the solution? Here I’ll introduce a mechanism called “algebraic effects”. In short, algebraic effects are exceptions with return values.

let cachedPosts: Posts[] | null = null;

async function fetchDelayedPosts(): Promise<Post[]>;

function getPosts(): Post[] {
  if (!cachedPosts) {
    throw new PostNotPreparedError('posts are not prepared');
  }
  return cachedPosts;
}

try {
  const posts = getPosts();
  console.log(posts);
} catch (e) {
  console.error('can\'t get posts');
}

First, we just throw a normal error if posts are not fetched. It will be caught by the try-catch clause and print “can’t get posts”. The whole function finished if an error was thrown, but what if we can fetch posts in the catch clause and resume the function with fetched data?

// pseudo code:
let cachedPosts: Posts[] | null = null;

async function fetchDelayedPosts(): Promise<Post[]>;

function getPosts(): Post[] {
  if (!cachedPosts) {
    cachedPosts = throw new PostNotPreparedError('posts are not prepared');
  }
  return cachedPosts;
}

try {
  const posts = getPosts();
  console.log(posts);
} catch (e) {
  if (e instanceof PostNotPreparedError) {
    fetchDelayedPosts().then(posts => resume posts);
  }
}

Here are some syntaxes not in ECMAScript, reading the value from throw statement and the resume statement. Although we called the async function fetchDelayedPosts, getPosts itself is still a synchronous function. What’s more, we always deal with plugins when using frameworks and libraries. With algebraic effects, we can donate and make a part of code pluggable easily. Just throw some algebraic effects, and developers can catch these effects and provide the data.

Implement the Effects

Everything sounds perfect, however, we don’t have such a syntax at present. We can simulate this syntax by the native throw statement. Create a run function to manage this program.

let cachedPosts: Posts[] | null = null;

async function fetchDelayedPosts(): Promise<Post[]>;

class PostNotPreparedError extends Error {
  constructor(public promise) {
    super('posts are not prepared');
  }
}

function getPosts(): Post[] {
  if (!cachedPosts) {
    throw new PostNotPreparedError(fetchDelayedPosts());
  }
  return cachedPosts;
}

function run(fn: Function) {
  try {
    fn();
  } catch (e) {
    e.promise.then(posts => {
      cachedPosts = posts;
      run(fn);
    });
  }
}

run(() => {
  const posts = getPosts();
  console.info(posts);
});

cachedPosts is not fetched at the first run and throws an error. run catches the error and fetches posts, schedule a second run after posts are resolved. There isn’t any problem running a function twice if we assume it’s a pure function.

It just works, but not so perfectly. Consider if have more than one async task, the first value is resolved at the second run, but the second value is still not. So the executive order should be recorded in our implementation.

const cachedValues = [];
let position = 0;

class SuspenseError<T> extends Error {
  constructor(public promise: Promise<T>) {
    super('suspense error');
  }
}

function getPosts(): Post[] {
  if (!cachedValues[position]) {
    throw new SuspenseError(fetchDelayedPosts());
  }
  return cachedValues[position++];
}

function getUser(): User {
  if (!cachedValues[position]) {
    throw new SuspenseError(fetchDelayedUser());
  }
  return cachedValues[position++];
}

function run(fn: Function) {
  try {
    position = 0;
    fn();
  } catch (e) {
    e.promise.then(value => {
      cachedValues[position] = value;
      run(fn);
    });
  }
}

run(() => {
  const posts = getPosts();
  const user = getUser();
  console.info(posts, user);
});

Maybe you have already known how React hooks work, it’s really similar to hooks.

React Hooks

Now we try to implement a useState hook behave like this:

run(() => {
  const [state, setState] = useState(0);
  console.info(state);

  if (state === 0) {
    setState(1);
  }
});

// output:
// 0
// 1

It’s similar to suspense, the only difference is that suspense is re-run when the promise resolved, but useState re-run when we call setState.

const cachedValues = [];
let position = 0;

function run(fn: Function) {
  try {
    position = 0;
    fn();
  } catch (e) {
    const temp = position;
    e.promise.then(value => {
      cachedValues[temp] = value;
      position = temp + 1;
      run(fn);
    });
  }
}

function useState<T>(value?: T) {
  if (!cachedValues[position]) {
    function set(newValue: T) {
      throw new SuspenseError(Promise.resolve([newValue, set]));
    }
    // FIXME: it causes infinite loop, because position is not recorded
    set(value);
  }
  return cachedValues[position++];
}

However, we find it runs into an infinite loop because the position is not recorded. It has already increased to 1 when we call the set function, even we wrap the position in a closure. That is to say, the position should be recorded earlier, or delay other parts in the program. We should abandon the promise, and find a new way to solve this problem.

Continuation

Continuation is a type defined as follows:

// (T => T) => T
type Continuation<T> = (fn: (arg: T) => T) => T;

// T => Continuation<T>
// x => fn => fn(x)
resolve = (x: T) => (fn: (arg: T) => T) => fn(x);

// Continuation<T> => (T => Continuation<T>) => Continuation<T>
// (continuation, next) => fn => continuation(x => next(x)(fn))
then = (continuation: Continuation<T>, next: (arg: T) => Continuation<T>) => {
  return (fn: (arg: T) => T) => continuation(x => next(x)(fn));
};

It’s really similar to promise

// T => Promise<T>
Promise.resolve

// Promise<T> => (T => Promise<T>) => Promise<T>
Promise.prototype.then

The type of resolve and then in continuation is reasonable, but where does the implementation come from? In fact, the resolve and then in promise have such properties:

promise.then(Promise.resolve) ~~ promise
Promise.resolve(x).then(next) ~~ next(x)

Try to verify these properties in continuation. We can just replace the parameters in function with its arguments, and simplify patterns like x => f(x) to f (forget your knowledge in programming and recall the mathematics, you are calculating an equation now).

then(continuation, resolve)
  = fn => continuation(x => resolve(x)(fn))
  = fn => continuation(x => (y => y(x))(fn))
  = fn => continuation(x => fn(x))
  = fn => continuation(fn)
  = continuation

then(resolve(a), next)
  = fn => resolve(a)(x => next(x)(fn))
  = fn => (y => y(a))(x => next(x)(fn))
  = fn => (x => next(x)(fn))(a)
  = fn => next(a)(fn)
  = next(a)

That’s it. It matches the properties perfectly. Continuation has similar properties to promise, and the key difference is that a function won’t execute until it’s passed with arguments. That’s exactly what we want. Let’s replace the Promise with the Continuation to finish the useState hook.

class SuspenseContinuationError<T> extends Error {
  constructor(public continuation: Continuation<T>) {
    super('suspense error');
  }
}

const Continuation = {
  resolve: x => fn => fn(x),
  then: (continuation, next) => fn => continuation(x => next(x)(fn)),
}

function useState(value?: T) {
  if (!cachedValues[position]) {
    throw new SuspenseContinuationError(makeContinuation(value));
  }
  return cachedValues[position++];
}

function makeContinuation<T>(value: T) {
  return fn => fn([value, function next(nextValue) { fn([nextValue, next]) }]);
}

function run(fn) {
  try {
    position = 0;
    return Continuation.resolve(fn);
  } catch (e) {
    const temp = position;
    return Continuation.then(e.continuation, value => {
      cachedValues[temp] = value;
      position = temp + 1;
      return run(fn);
    });
  }
}

run(() => {
  const [state, setState] = useState(0);
  console.info(state);

  if (state === 0) {
    setState(1);
  }
})(() => {}); // call with an empty function since it's a continuation

How can we understand the similarity between promise and continuation? They both map a type to another related type and keep some properties. In fact, it’s like the concept widely used in functional programming named “Monad”. It’s an abstract concept so I can’t describe it vividly, but it obeys the properties mentioned above.

Maybe you want to read 《Category Theory for Programmers》

Conclusion

Here I introduced React’s new feature, suspense in data fetching and hooks, what are the problems React meets and how does it resolve problems in a functional way. When it comes to functional programming, the first thing that comes out may be the pure function, immutability, high order function, etc. It’s correct but not only that. It not only refers to language features or how we write the code, it’s more like how we think, how can we get an abstract solution to resolve more problems. Thank you.