skip to Main Content

I want to do a simple react application that when I hit enter on a block, a new block will show up under that block and the rest behind will be under the new one.

But when there are mutiple blocks, when I hit enter on a slightly previous one, the later will disappear.

I do not understand it. Can someone point out the bug?

Here are some codes and pictures:

editablePage.tsx

import { useState } from "react";
import EditableBlock, { BlockType } from "../editableBlock";

export default function EditablePage() {
  const [blocks, setBlocks] = useState<BlockType[]>([
    { tag: "h1", content: "Welcome", position: 0 },
  ]);

  function addBlockHandler({ tag, position }: BlockType) {
    const nextPosition = position + 1;
    const newBlock: BlockType = {
      tag: tag,
      content: nextPosition.toString(),
      position: nextPosition,
    };
    console.log(blocks);
    const blocksBeforeNew = blocks.slice(0, nextPosition);
    const blocksAfterNew = blocks.slice(nextPosition).map((block) => {
      const copy = { ...block };
      copy.position += 1;
      return copy;
    });
    const updatedBlocks = blocksBeforeNew
      .concat(newBlock)
      .concat(blocksAfterNew);
    setBlocks(updatedBlocks);
  }

  return (
    <div>
      {blocks.map(({ tag, content, position }: BlockType) => {
        return (
          <EditableBlock
            key={position}
            tag={tag}
            position={position}
            content={content}
            addBlock={addBlockHandler}
          />
        );
      })}
    </div>
  );
}

editableBlock.tsx

import { useState } from "react";
import ContentEditable, { ContentEditableEvent } from "react-contenteditable";

type TagType = "h1" | "h2" | "h3" | "p";

export interface BlockType {
  tag: TagType;
  content: string;
  position: number;
}

export interface EditableBlockProps extends BlockType {
  addBlock: (currentBlock: BlockType) => void;
}

export default function EditableBlock({
  tag,
  content,
  position,
  addBlock,
}: EditableBlockProps) {
  const [text, setText] = useState<string>(content);

  const handleChange = (evt: ContentEditableEvent) => {
    setText(evt.target.value);
  };

  const handleKeyDown = (evt: React.KeyboardEvent<HTMLElement>) => {
    if (evt.key === "Enter") {
      evt.preventDefault();
      addBlock({ tag, content, position });
    }
  };

  return (
    <ContentEditable
      tagName={tag}
      html={text}
      onChange={handleChange}
      onKeyDown={handleKeyDown}
    />
  );
}

Before:

before

After hitting enter on the first block:

hit enter on the first block

I have figured out that the bug came from blocks, but I do not understand why this happens.

2

Answers


  1. This is a known issue of react-contenteditable, see lovasoa/react-contenteditable#161:

    react-contenteditable has to prevent rendering (using shouldComponentUpdate) very frequently. Otherwise, the caret would jump to the end of the editable element on every key stroke. With useState, you create a new onBlur event handler on every keystroke, but since the ContentEditable is preventing rendering, your event handlers are not taken into account on every keystroke, and it’s the handler function that was creating the last time the component was rendered that gets called.

    Among the workarounds proposed in the linked issue, you can try the one mentioned in that comment, which is a variation of the useEventCallback shown in legacy React docs > How to read an often-changing value from useCallback?:

    const useRefCallback = <T extends any[]>(
      value: ((...args: T) => void) | undefined,
      deps?: React.DependencyList
    ): ((...args: T) => void) => {
      const ref = React.useRef(value);
    
      React.useEffect(() => {
        ref.current = value;
      }, deps ?? [value]);
    
      const result = React.useCallback((...args: T) => {
        ref.current?.(...args);
      }, []);
    
      return result;
    };
    
    // Usage
    export function EditablePage() {
      // State, addBlockHandler function...
    
      const addBlock2 = useRefCallback(addBlockHandler);
    
      return (
        <div>
          {blocks.map(({ tag, content, position }: BlockType) => {
            return (
              <EditableBlock
                key={position}
                tag={tag}
                position={position}
                content={content}
                addBlock={addBlock2} // Use the wrapped version instead of directly addBlockHandler
              />
            );
          })}
        </div>
      );
    }
    
    Login or Signup to reply.
  2. Building on what @ghybs wrote in their answer, while a useRef or the useRefCallback mentioned there would work, in this case the only reason you need the updated addBlockHandler function is because it is capturing the current value of blocks to update it.

    With that in mind, you could use the state updater function version of setBlocks to use its current value:

      function addBlockHandler({ tag, position }: BlockType) {
        const nextPosition = position + 1;
        const newBlock: BlockType = {
          tag: tag,
          content: nextPosition.toString(),
          position: nextPosition
        };
    
        setBlocks((currentBlocks) => {
          console.log(currentBlocks);
          const blocksBeforeNew = currentBlocks.slice(0, nextPosition);
          const blocksAfterNew = currentBlocks.slice(nextPosition).map((block) => {
            const copy = { ...block };
            copy.position += 1;
            return copy;
          });
          const updatedBlocks = blocksBeforeNew
            .concat(newBlock)
            .concat(blocksAfterNew);
          return updatedBlocks;
        });
      }
    

    However, there is another issue here. Since you are using position as your EditableBlock key, when you shift each block’s position to try to add a new one in the middle of the existing array, React sees the same values except for at the end. For example, if you had positions 0, 1, 2, 3, and 4, and you want to insert a new block after position 2, React sees positions 0, 1, 2, 3, 4, and 5 (where 3 is the new object, but it’s reusing an old position, and 4/5 are the old objects but have their positions shifted). The position property in this case is really just a glorified index. React would then assume that the object at the end is the new one, since that’s the only new key. This will give you unexpected results when adding elements to the middle.

    The solution to this would be to keep track of a separate unique ID to use as a key that would stay consistent within the lifetime of each object.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search