Working with react stale state

So last week I'm working on side project called nocion which is just a clone of notion. In this project I will try to make everything from scratch and simple. And I found some interesting problem here.

Scenario

This time in order to clone on of the most crucial feature in notion is block editor. So everytime we press Enter key inside block editor it will generate new block editor. The block editor it self will be stored inside array state. So everytime we receive enter event we will update that array.

import React, { useState, useEffect } from "react";
import "./styles.css";

function BlockEditor({ text, addNewBlock }) {
  useEffect(() => {
    window.addEventListener("keyup", onKeyUp);
    window.addEventListener("keydown", onKeyDown);
    return () => {
      window.removeEventListener("keyup", onKeyUp);
      window.removeEventListener("keydown", onKeyDown);
    };
  }, []);

  function onKeyUp(event) {
    if (blockRef.current !== document.activeElement) return;
    if (event.key === "Enter") {
      addNewBlock({ text: "" });
      event.preventDefault();
    }
  }

  function onKeyDown(event) {
    if (blockRef.current !== document.activeElement) return;
    if (event.key === "Enter") {
      event.preventDefault();
    }
  }

  return (
    <h2 suppressContentEditableWarning={true} contentEditable={true}>
      {text}
    </h2>
  );
}

export default function App() {
  const [blocks, appendBlock] = useState([
    {
      text: "Let's edit this page 🚀",
      index: 0
    }
  ]);

  function addNewBlock(block) {
    const newBlock = [...blocks, { ...block, id: blocks.length + 1 }];
    console.log(newBlock);
    appendBlock(newBlock);
  }

  return (
    <div className="App">
      <h1>Callback State</h1>
      {React.Children.toArray(
        blocks.map(({ text }) => (
          <BlockEditor text={text} addNewBlock={addNewBlock} />
        ))
      )}
    </div>
  );
}

If you see this code is look okay and even in console log we can see the correct data logged we expected to have new line added to the addNewBlock function. But the dom is not updated.

Stale Value

This is because we are working with the stale values. In short stale value is the value that we use is out of date, it mean we still use the previous value. Let see this example components.

function AsyncCount() {
  const [count, setCount] = useState(0);
  function incrementAsync() {
    setTimeout(function delay() {
      setCount(count + 1);
    }, 1000);
  }

  return (
    <div>
      {count}
      <button onClick={incrementAsync}> Increment</button>
    </div>
  );
}

This component will update the count value every one second after we click. It will working fine if we increment the value on linear time or we process it in low traffic. But if we run increment the value 10 times under one second we will not receive count value to be 10. To solve that issue we can make sure we work with the previous value of count state. The solution will be a simple like this.

setTimeout(function delay() {
    setCount((count) => count + 1);
}, 1000);

Solution

So let's get back with the blocks editor. How to fix the issue? It easy just update the array blocks using corrent previous value.

function addNewBlock(block) {
    appendBlock((prevBlock) =>
        prevBlock.concat([{ ...block, id: blocks.length + 1 }])
    );
}

Reference

If you need more deep explanation read here.

  1. dmitripavlutin - React Hooks Stale Closures
  2. kentcdodds - useState lazy initialization
  3. stackoverflow - stale state

Demo Project

If you like to try the project just play with this example demo project.

Interesting about the nocion project visit here.