Normalizing

PreviousNext

Slate editors can edit complex nested data. Normalizing is the part of the runtime that keeps that data in a shape the rest of the editor can reason about.

Validation only tells you that a document is invalid. Normalizing fixes the document.

Built-in Constraints

Slate has a few built-in constraints. They make browser selection, operations, history, and collaboration predictable.

  1. Every Element has a text descendant. Even void elements keep an empty text child so selections can always point into text.
  2. Adjacent text nodes with the same properties merge. Formatting can split text nodes; normalization merges equal neighbors back together.
  3. Blocks contain either blocks, or inline/text content. A block cannot mix block children with inline/text children.
  4. Inline nodes have text on both sides. Empty text nodes are inserted before, after, or between inline nodes when needed.
  5. The editor root contains block nodes. Top-level inline or text nodes are removed.
  6. Nodes must be JSON-serializable. Operations and snapshots need to move cleanly through storage and collaboration layers.
  7. Node property values should not be null. Use optional properties instead. Slate operations use null to describe removed properties.

These constraints are not app preferences. They are the base document contract Slate relies on.

Fix One Problem At A Time

Normalizing is multi-pass. A normalizer should fix one invalid structure and return. If that repair changes an ancestor, Slate will visit the affected nodes again.

Imagine this invalid document:

<editor>
  <paragraph a>
    <paragraph b>
      <paragraph c>word</paragraph>
    </paragraph>
  </paragraph>
</editor>
<editor>
  <paragraph a>
    <paragraph b>
      <paragraph c>word</paragraph>
    </paragraph>
  </paragraph>
</editor>

The innermost paragraph is valid. The next paragraph is not, because it contains another block. A normalizer can unwrap that child and stop:

editor.update((tx) => {
  tx.nodes.unwrap({ at: [0, 0] })
})
editor.update((tx) => {
  tx.nodes.unwrap({ at: [0, 0] })
})

That repair changes the parent, so Slate can normalize the parent on the next pass. The document eventually becomes:

<editor>
  <paragraph a>word</paragraph>
</editor>
<editor>
  <paragraph a>word</paragraph>
</editor>

This is the important habit: repair one concrete problem, then let the runtime schedule the next pass.

Empty Children Run Early

The empty-child constraint runs before other normalizing work. If an element has no children, Slate inserts an empty text descendant first.

That matters for custom structures. If a table with no rows should be removed, do not wait for a "no children" shape that never appears. Check for the structure your editor actually stores after Slate adds the empty text descendant.

Incorrect Fixes

Avoid repairs that still leave the node invalid.

editor.update((tx) => {
  tx.nodes.set({ url: null }, { at: path })
})
editor.update((tx) => {
  tx.nodes.set({ url: null }, { at: path })
})

That looks like it clears an invalid URL, but null is not a valid node property value in Slate documents. The node is still invalid, so the normalizer can loop forever.

Prefer a repair that produces a valid shape:

editor.update((tx) => {
  tx.nodes.unset('url', { at: path })
})
editor.update((tx) => {
  tx.nodes.unset('url', { at: path })
})

Or remove the invalid wrapper entirely:

editor.update((tx) => {
  tx.nodes.unwrap({ at: path })
})
editor.update((tx) => {
  tx.nodes.unwrap({ at: path })
})

Grouped Repairs

Sometimes a command needs several structural edits before the tree is valid again. Wrap related writes in one editor.update(...), and use tx.withoutNormalizing(...) when the tree should not normalize between those writes.

import { ElementApi, type Editor } from '@platejs/slate'
 
const LIST_TYPES = ['numbered-list', 'bulleted-list']
 
function changeBlockType(editor: Editor, type: string) {
  editor.update((tx) => {
    tx.withoutNormalizing(() => {
      const isActive = isBlockActive(editor, type)
      const isList = LIST_TYPES.includes(type)
 
      tx.nodes.unwrap({
        match: (node) =>
          ElementApi.isElement(node) && LIST_TYPES.includes(node.type),
        split: true,
      })
 
      tx.nodes.set({
        type: isActive ? 'paragraph' : isList ? 'list-item' : type,
      })
 
      if (!isActive && isList) {
        tx.nodes.wrap({ type, children: [] })
      }
    })
  })
}
import { ElementApi, type Editor } from '@platejs/slate'
 
const LIST_TYPES = ['numbered-list', 'bulleted-list']
 
function changeBlockType(editor: Editor, type: string) {
  editor.update((tx) => {
    tx.withoutNormalizing(() => {
      const isActive = isBlockActive(editor, type)
      const isList = LIST_TYPES.includes(type)
 
      tx.nodes.unwrap({
        match: (node) =>
          ElementApi.isElement(node) && LIST_TYPES.includes(node.type),
        split: true,
      })
 
      tx.nodes.set({
        type: isActive ? 'paragraph' : isList ? 'list-item' : type,
      })
 
      if (!isActive && isList) {
        tx.nodes.wrap({ type, children: [] })
      }
    })
  })
}

The update still publishes one commit. withoutNormalizing only delays normalization until the grouped repair has finished.

Extension Normalizers

Reusable schema rules belong in editor extensions. Extensions can register named normalizer entries alongside element specs, state groups, tx groups, commit listeners, operation middleware, and setup output.

Use the same rule inside those entries: make repairs with transaction methods, fix one invalid structure at a time, and avoid direct mutable editor fields.