Skip to content

Custom Atom Nodes

Jayden Smith edited this page Sep 7, 2026 · 6 revisions

Custom Atom Nodes

Custom atoms are schema-declared block void nodes whose presentation is a React component supplied by the application. Use them for persisted, interactive document objects such as counters, status cards, embeds, or structured controls that do not contain editable prose.

The schema owns the node and its attributes. RichTextEditor and RichTextViewer mount the component into native text layout. In the editor, the component can update declared attributes through a revision-guarded document transaction, so its state survives JSON or HTML persistence, undo, collaboration, and a later editing session.

Define and mount an atom

Define the component, its attributes, and its HTML representation together. Add the definition to the handle schema and pass it to the editor's atoms prop:

import { useEffect, useMemo, useRef } from 'react';
import { Pressable, Text, View } from 'react-native';
import {
  AtomUpdateAttrsError,
  createNativeEditorDocumentHandle,
  defaultSchema,
  defineAtomNode,
  defineSchema,
  RichTextEditor,
  withAtomsSchema,
  type AtomComponentProps,
  type RichTextEditorRef,
} from '@apollohg/react-native-rich-text-editor';

type CounterAttrs = { title: string; count: number };

function CounterCard({ attrs, selected, readOnly, updateAttrs }: AtomComponentProps<CounterAttrs>) {
  const { title, count } = attrs;

  const increment = async () => {
    try {
      await updateAttrs((current) => ({ count: current.count + 1 }));
    } catch (error) {
      if (error instanceof AtomUpdateAttrsError) {
        console.warn(error.code);
      }
    }
  };

  return (
    <View accessibilityState={{ selected }}>
      <Text>{title}</Text>
      <Pressable disabled={readOnly} onPress={increment}>
        <Text>Count: {count}</Text>
      </Pressable>
    </View>
  );
}

const counterCard = defineAtomNode({
  name: 'counterCard',
  attrs: {
    title: { type: 'string', default: 'Untitled counter' },
    count: { type: 'number', default: 0 },
  },
  html: {
    tag: 'div',
    staticAttrs: { 'data-type': 'counter-card' },
    attrMap: { title: 'data-title', count: 'data-count' },
  },
  component: CounterCard,
  estimatedHeight: 120,
});

const schema = withAtomsSchema(defaultSchema, [counterCard]);

export function CounterEditor() {
  const editorRef = useRef<RichTextEditorRef>(null);
  const documentHandle = useMemo(
    () =>
      createNativeEditorDocumentHandle({
        schema,
        initialization: { type: 'localHtml', html: '<p>Counter demo</p>' },
      }),
    []
  );

  useEffect(() => () => documentHandle.destroy(), [documentHandle]);

  return (
    <RichTextEditor
      ref={editorRef}
      documentHandle={documentHandle}
      atoms={[counterCard]}
    />
  );
}

The same definitions can be composed while authoring a keyed schema:

const schema = defineSchema({
  nodes: {
    doc: { content: 'block+' },
    paragraph: { content: 'inline*', group: 'block', toDOM: ['p', 0] },
    text: { group: 'inline' },
  },
  atoms: [counterCard],
});

The handle and editor have separate responsibilities: the handle schema must contain every atom node that may occur in the document, while atoms supplies the React renderers for this mounted editor. If the schema contains a custom void block without a matching renderer, the editor shows a built-in chip instead of an invisible blank line.

Insert atoms

Insert a node with its schema defaults through the editor ref:

editorRef.current?.insertNode(counterCard.name);

Use the definition's fragment helper when the inserted atom needs explicit attributes:

editorRef.current?.insertContentJson(
  counterCard.buildFragmentJson({ title: 'Sample item', count: 10 })
);

buildFragmentJson() uses doc as the root by default. For a custom document root, pass the descriptor returned by resolveDocumentDescriptor(schema) as its second argument.

Atoms participate in native selection and deletion as one document node. A component receives selected: true for a node selection, an all selection, or a text range that fully covers it. Backspace at the adjacent boundary removes the atom through the normal editor transaction path.

Persist interactive state

updateAttrs(partial) updates only the supplied declared attributes and returns a promise. A successful update changes the authoritative document; it is not component-local state. Save the resulting onContentChangeJSON or onContentChange output and initialize another compatible handle with that JSON or HTML to restore the same attributes in a later session.

For example, the counter above is represented in JSON as:

{
  "type": "counterCard",
  "attrs": { "title": "Sample item", "count": 10 }
}

Its HTML representation is equivalent to:

<div data-type="counter-card" data-title="Sample item" data-count="10"></div>

Updates also create normal undo entries and local commits. On a room handle, they enter the shared Yjs document and reach other editors using the same schema. Treat attrs as the rendered snapshot: await updateAttrs() and let the next props update drive the component rather than maintaining a second durable copy.

Update failures

Editor update failures and viewer validation/lifecycle failures reject with AtomUpdateAttrsError. Errors thrown by an application updater or viewer persistence handler propagate unchanged. AtomUpdateAttrsError.code is:

Code Meaning
not-applicable The position no longer identifies an applicable atom, or the partial update cannot apply.
stale-revision The document changed before the revision-guarded update could apply. Re-read the next props before deciding whether to retry the user intent.
not-ready The handle is unavailable, destroyed, or not ready.
engine-error The engine rejected or could not complete the update.

Only attributes declared in the atom definition can be changed. Do not use allowUndeclaredAttrs; atom definitions reject that escape hatch.

HTML rules

html is required so JSON and HTML conversion can identify the atom and preserve every declared attribute.

Field Contract
tag A safe lowercase HTML tag for the void element.
staticAttrs At least one fixed discriminator, such as data-type="counter-card".
attrMap Maps every declared node attribute to one unique HTML attribute. When omitted, names are converted to kebab-cased data-* attributes.

Every declared attribute must have a mapping. Mapping targets must be unique and cannot collide with staticAttrs. Event-handler attributes and unsafe tags or attributes are rejected. Atom definitions using the same tag must have unambiguous static discriminators. Scalar values round-trip directly; supported structured values are JSON-encoded in HTML and restored on parsing.

API reference

AtomNodeConfig and AtomNodeDefinition

Field Contract
name Required non-empty node name. Reserved wire names are rejected.
attrs Optional declared Record<string, AttrSpec>. An omitted default makes the attribute required.
html Required NodeHtmlRules for lossless parsing and serialization.
component Required AtomComponent.
estimatedHeight Optional non-negative initial layout estimate; defaults to 32. Native layout replaces it with the measured component height.
idAttribute Optional declared string attribute used as stable viewer identity.
nodeSpec Compiled block, void NodeSpec exposed by the returned definition.
buildFragmentJson(attrs?, descriptor?) Builds a one-atom DocumentJSON fragment for insertion.

AtomComponentProps

Prop Type Meaning
attrs Readonly<A> Current persisted attributes, inferred from the definition or declared with AtomComponentProps<A>.
selected boolean Whether the editor selection covers the atom.
nodeType string The schema node name.
updateAttrs (update: AtomAttrsUpdate<A>) => Promise<void> Partial object, pure updater, or array of these.
readOnly boolean Persisted attributes cannot be changed.
interactive boolean Whether the host enables atom input.
isViewer boolean Viewer rendering with app-owned persistence.
updatePending boolean At least one attribute update is awaiting completion.
updateError Error | null Error from the latest-started request if it rejects; cleared when another update starts.
editor AtomEditorActions | undefined Editor-only selection, deletion and caret actions.
setActive (active: boolean) => void Pins a card during playback or another activity that must survive scrolling.

Composition and low-level helpers

Export Contract
defineAtomNode(config) Validates and compiles one component-backed block atom.
withAtomsSchema(schema, atoms) Adds definitions once, rejects conflicting node specs, and checks HTML-rule ambiguity.
serializeEditorAtoms(atoms?) / SerializedEditorAtoms Serializes registered node types and estimated heights for the native editor boundary. Most applications do not call this directly.
AtomInstance / collectAtomInstances() Derives mounted atom identity, attrs, and document position from render blocks.
applyRenderPatch() Applies a RenderBlocksPatch to previously prepared render blocks.
atomSelected() Tests whether an engine selection covers one atom position.
DEFAULT_ATOM_CHIP_HEIGHT 32, used for the fallback chip.
NATIVE_VOID_BLOCK_TYPES Built-in native void blocks excluded from custom atom mounting unless explicitly registered.

Typed attributes and validation

Definitions infer component attributes, partial updates and fragment inputs from their declarations. Defaults make insertion attributes optional; attributes without defaults are required. Explicit AtomComponentProps<A> and defineAtomNode<A> are available for separately declared components. Unconstrained legacy declarations remain supported.

AttrSpec accepts optional type (string, number, boolean, object, array), enum, min, and max. Bounds apply to numbers, Unicode string lengths, or array lengths and require an explicit compatible type. Enum values must share one JSON type. Defaults and fragment inputs are checked in JavaScript; Rust validates imported documents and mutation candidates. Objects and arrays must contain finite JSON values. Constraints also apply when importing HTML; explicit types and enums determine coercion.

Adding constraints changes the schema fingerprint. Coordinate schema changes across collaborating peers and persisted snapshots.

Frequent updates and undo grouping

Use a pure updater for changes based on the current value:

await updateAttrs((current) => ({ count: current.count + 1 }));

The editor resolves the current atom by identity and reads its latest attributes at invocation. Updaters receive a detached, frozen snapshot. Avoid side effects inside them. To combine changes into one editor transaction and one undo step, pass an array:

await updateAttrs([
  { title: 'Next set' },
  (current) => ({ count: current.count + 1 }),
]);

Each updater sees preceding changes in the array. For a slider, keep the preview locally and submit the final value when the gesture ends. updatePending and updateError let the component display progress and failures; callers must still handle the returned promise. Functional updates are revision-guarded attribute assignments, not commutative collaborative counters. Concurrent user intentions may require application-specific conflict handling.

Read-only interaction and viewer persistence

Pass the same definitions to RichTextViewer. Its prose always remains read-only. Atom readOnly defaults to true and prevents attribute changes; atomsInteractive defaults to true independently, allowing links, playback and local expansion. Components should disable mutation controls using readOnly and input controls using interactive. Setting readOnly={false} without a handler still passes false to the component, but updateAttrs() rejects with not-applicable.

To persist viewer changes, set readOnly={false}, handle onUpdateAtomAttrs, and provide updated content:

<RichTextViewer
  contentJSON={content}
  atoms={atoms}
  readOnly={false}
  onUpdateAtomAttrs={async ({ atomId, nodeType, docPos, partial }) => {
    const nextContent = await saveAtomChange({ atomId, nodeType, docPos, partial });
    setContent(nextContent);
  }}
/>

The viewer never mutates a document. Resolving its update promise means the app handler completed; it does not mean new props have been rendered. Requests for the same atom are serialized, and functional updates use attributes acknowledged by prior successful handlers while the source remains current. A source/configuration/width change invalidates old callbacks and obsolete queued requests. Reject failed persistence in the handler so later updates do not build on a failed change.

Stable viewer identity

Set idAttribute: 'id' and declare id: { type: 'string' } when cards need to preserve local state across content moves or updates. Supply a unique, non-empty ID per atom type in JSON and HTML. onUpdateAtomAttrs includes atomId alongside the snapshot-relative docPos. Use the ID for durable persistence; document positions change as content is edited.

Assign a fresh ID when duplicating a card. Missing or duplicate IDs in a prepared layout report INVALID_ATOM_LAYOUT; viewer updates cannot change an ID. Without an identity declaration, source replacements remount cards so state is never silently transferred to another same-type card at the same position. Width-only layout changes retain instances. The editor continues to use its engine-provided stable identity.

Editor controls

An editor atom receives editor.select(), editor.delete(), editor.focusBefore(), and editor.focusAfter(). These return promises, resolve the current node position and reject stale or deleted instances. Deletion is undoable and requires an editable editor. Selection and caret actions are available without mutation permission. At a document edge, caret placement uses the nearest legal position; it does not insert a paragraph. The viewer omits editor.

Renderer recovery and large documents

Each atom has an error boundary with a labeled fallback and Retry control. Lazy renderers also have a loading placeholder. Renderer failures leave other cards and surrounding prose mounted. Errors from event handlers and asynchronous app code still belong to the component.

Virtualization is opt-in:

  • RichTextEditor virtualizeAtoms uses its native internal scroll viewport.
  • For an editor inside an external scroll container, pass atomViewport={{ y, height, overscan }} in the coordinates emitted by native atom layout.
  • RichTextViewer atomViewport={{ y, height, overscan }} uses the visible range relative to viewer content. Derive it from the containing scroll view.

overscan defaults to 200 points. Offscreen React renderers unmount while native hosts retain measured spacer heights. Width changes invalidate cached sizes. Focused, selected, actively updating and explicitly pinned cards stay mounted. Call setActive(true) while a card is playing media or holding another important transient interaction, then setActive(false) when finished. Persist durable state in attributes or app state: ordinary local React state resets when an unpinned renderer scrolls out of range. Native spacer hosts and document layout remain allocated, so this primarily reduces expensive React component work. On Android, mounted children are positioned in the editor content frame and measured heights update their existing native atom spans in place. Height changes request layout without replacing the editor text; estimates are initial placeholders, not fixed card sizes.

Current limits

  • Atoms are block-level void nodes; inline views and editable custom prose containers are not supported.
  • Components own their visual accessibility and interactive children. Use ordinary React Native controls and meaningful labels.
  • Viewer persistence is controlled by the app; it has no editor history or document mutations.

Use ordinary React Native styles inside atom components. Document theme slots use the flat Styling API; they do not automatically style the component’s children.

See Schema Customization for the keyed schema API and Content and State for persistence choices.

Clone this wiki locally