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:
After hitting 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
This is a known issue of
react-contenteditable
, see lovasoa/react-contenteditable#161: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 fromuseCallback
?:Building on what @ghybs wrote in their answer, while a
useRef
or theuseRefCallback
mentioned there would work, in this case the only reason you need the updatedaddBlockHandler
function is because it is capturing the current value ofblocks
to update it.With that in mind, you could use the state updater function version of
setBlocks
to use its current value:However, there is another issue here. Since you are using
position
as yourEditableBlock
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.