---
title: Properties
description: Block, inline block and document properties (data), read like plain objects, written with patchData, synced per property, with undo, hooks and typing.
icon: sliders-horizontal
---

Every block, every inline block and the document itself carry `data`: a JSON object of properties, such as a to-do's `checked`, an image's `src` or a document's `title`. You read it like a plain object and write it with the `patchData` operation (undoable, visible to plugins, refused in a readonly view). The document syncs it property by property, so two people editing different properties of the same block both keep their change.

## Read and write

`block.data`, `atom.data` and `edytor.data` read the current value; `edytor.patchData(target, patches)` writes it, where `target` is the block's id, `{ block, atom }` for an inline block, or `null` for the document:

```ts title="src/lib/properties.ts" check
const read = block.data.title; // read one property
edytor.patchData(block.id, [{ path: ['title'], value: 'Launch plan' }]); // set one property
edytor.patchData(block.id, [{ path: ['meta', 'owner'], value: 'ada' }]); // a nested one
edytor.patchData(block.id, [{ path: ['draft'] }]); // delete one
edytor.patchData(block.id, [{ path: ['tags'], splice: [1, 0, 'q3'] }]); // one item added
edytor.patchData(block.id, [{ path: ['tasks', '0', 'done'], value: true }]); // one property of one item
edytor.patchData(null, [{ path: ['title'], value: 'Notes' }]); // the document's own properties
edytor.patchData({ block: block.id, atom: 'm1' }, [{ path: ['name'], value: 'Ada' }]); // an inline block
edytor.patchData(block.id, [{ path: [], value: { title: 'Fresh' } }]); // replace the whole object
void read;
```

- A read returns the current value, which you must not mutate: copy it (`{ ...block.data }`, `structuredClone(block.data)`) to change a copy or to keep what it was at one moment.
- A write writes only what its patches change (the [patches](#commands-hooks-and-undo) below). Values are JSON: a `Date` becomes a string and `undefined` removes the property (see [the document model](/docs/concepts/document-model)); a value that is not JSON is converted and logged as a warning in development.
- Reads in a template are reactive: a snippet that reads `block.data.title` re-renders when anyone changes the block, you, a plugin or a collaborator. `edytor.data` is reactive too, anywhere in your app.
- `edytor.root.data` is `edytor.data`.

## Bind to inputs

Bind a property through a getter and a setter that writes it. A kind whose snippet edits its own data:

```svelte title="CardPlugin.svelte" check
<script module lang="ts">
	import type { BlockSnippetPayload, BlockView, Plugin } from 'edytor';

	type Card = { title?: string; done?: boolean; tags?: string[] };

	export const cardPlugin: Plugin = () => ({
		blocks: {
			card: {
				snippet: card,
				presets: [{ label: 'Card', icon: '▤', data: { title: '', done: false, tags: [] } }]
			}
		}
	});

	/** Write property `key` of the card: one `patchData`. */
	const set = (block: BlockView<Card>, key: keyof Card, value: unknown) =>
		block.handle?.edytor.patchData(block.id, [{ path: [key], value }]);
</script>

{#snippet card({ block, content }: BlockSnippetPayload<Card>)}
	<div use:block.void>
		<input
			placeholder="Title"
			bind:value={() => block.data.title ?? '', (title) => set(block, 'title', title)}
		/>
		<input
			type="checkbox"
			bind:checked={() => block.data.done ?? false, (done) => set(block, 'done', done)}
		/>
		<button
			onclick={() =>
				block.handle?.edytor.patchData(block.id, [
					{ path: ['tags'], splice: [block.data.tags?.length ?? 0, 0, 'new'] }
				])}>Tag</button
		>
		{block.data.tags?.join(', ')}
	</div>
	<div>{@render content()}</div>
{/snippet}
```

- An absent property reads as empty through the getter, so nothing is written when the input mounts; a plain `bind:` would write it, an edit (and an undo step) in every editable view that renders it.
- A row of an `{#each}` over an array writes its own item by id, so its write merges with a collaborator's edits of the others and stays on its item wherever they move it: `document.dataItemIds(block.id, ['tasks'])` lists the items' ids in order, and a patch names an item by its id (`['tasks', '~k3f9xq2a', 'done']`). A patch naming an item that is gone writes nothing.
- A kind's own controls own their events. Any form control in a kind's markup (an `<input>` of any type: text, date, time, number, range, color, a checkbox; a `<textarea>`, a `<select>`, a `<button>`) and any nested editable element that is not the editor's own text receives its keys, `beforeinput`, paste, copy, cut, drops and selection gestures from the browser, whatever the editor's selection: <kbd>Backspace</kbd>, <kbd>Enter</kbd> or <kbd>Tab</kbd> there never act on selected blocks, a paste never lands in the document, and the editor never moves its caret over a focused control while each change writes the block. Only a file dropped on one is swallowed (the browser would open it in place of the page), and <kbd>Backspace</kbd> or <kbd>Delete</kbd> on a focused `<select>` or `<button>` does nothing, so the browser deletes nothing around it.
- A readonly view refuses the writes, so the field would show what was typed until the next render: disable it there (`disabled={block.handle?.edytor.readonly}`; a [suggestion](/docs/editor/suggestions)'s preview has no handle).

## Document properties

The document's own data is `edytor.data` in a view, `document.docData()` headless, and the optional `data` of a `JSONDoc`:

```ts title="src/lib/notes.ts" check
import { createDocument } from 'edytor';

declare module 'edytor' {
	interface EdytorDocData {
		title?: string;
		tags?: string[];
	}
}

const document = createDocument({
	value: { data: { title: 'Notes' }, children: [{ type: 'paragraph', content: [{ text: 'Hi' }] }] }
});
document.patchData(null, [{ path: ['tags'], value: ['draft'] }]);
document.docData(); // { title: 'Notes', tags: ['draft'] }
document.toJSON().data; // the same; `data` is absent when the document has none
```

- A `value` with `data` seeds it with the content. The same seed on two replicas writes the same properties, whatever order its keys come in (a database may reorder them), like the content (see [Documents](/docs/collaboration/documents)).
- `edytor.value` (and `onChange`'s value) carries it as the root's `data`; `JSONDoc.data` round-trips through `toJSON`, the room's `read()`, `onSave` and `onLoad`.
- Augment `EdytorDocData` to type `edytor.data`, as above. Block data is typed per kind with `BlockSnippetPayload<T>` (`block.data` is a read-only `T` in the snippet).

## Commands, hooks and undo

A property write is the `patchData` operation: `patchData(target, patches)`, a hook's payload being `[target, patches]`. `patches` is a list, applied in order. `path` is the list of keys from the root, an array item's being its index as a string (`['tasks', '0', 'done']`) or its id (`['tasks', '~k3f9xq2a', 'done']`; `document.dataItemIds(target, path)` lists an array's ids in order). An id is `~` and a lowercase letter, so a key that starts with `~` is written with that `~` as `~0`, as in a JSON pointer (`['~0abc']` for the key `~abc`). A patch is one of:

- `{ path, value }` sets the value there; without `value` it deletes the property, or removes the item.
- `{ path, splice: [start, deleteCount, ...items] }` removes and inserts items of the array at `path`, as `Array.prototype.splice` does.
- `{ path, order }` moves the array's items: `order` lists the current indexes in their new order.

Indexes are resolved against the document when the command runs, ids wherever the item is then (a patch naming an item that is gone, removed or with its array deleted or replaced, is refused and writes nothing); the stored write names each item by its id, so it merges with what collaborators did meanwhile. The `block` is the edited block, the root block for the document.

```ts title="src/lib/locked.ts" check
import type { Plugin } from 'edytor';

// Validation lives in a hook. Only the app sets `locked`: refuse any write that
// would change it, a whole-object replace included.
export const locked: Plugin = () => ({
	onBeforeOperation: (change) => {
		if (change.operation !== 'patchData') return;
		const [target, patches] = change.payload;
		if (typeof target !== 'string') return; // an inline block's, or the document's
		const was = change.block.data.locked;
		const changes = (patch: (typeof patches)[number]) =>
			patch.path.length === 0
				? (patch.value as { locked?: unknown } | undefined)?.locked !== was
				: patch.path[0] === 'locked';
		if (patches.some(changes)) change.prevent();
	}
});
```

A patch with an empty `path` replaces the whole object, so a check must look inside its `value`, not only at `path[0]`: a replace that leaves `locked` out deletes it.

- A readonly view (or a read-only document) refuses every write, and `edytor.dispatcher.last` records `refused`.
- A vetoed write writes nothing. Edytor has no property schema: validate or normalize in `onBeforeOperation` (return a replacement payload to rewrite a patch).
- A block created with its properties is not a `patchData`: a paste, Duplicate and `insertBlocks` show as `insertBlocks` with the new blocks (`payload[1]`, `data` included), and a split's second half takes a copy of the first half's properties. A hook that guards a property checks `insertBlocks` too.
- Each write is one undo step. Typing into a property joins one step: a write of the same properties whose new strings each insert or delete one run of characters in the previous ones (`'Pla'` → `'Plan'`, `'Plan'` → `'Pan'`) continues the step while it comes within the history's capture window (`history.captureTimeout`, 500 ms by default), as typing text does. Any other write is its own step however fast it comes: a checkbox's `true`/`false`, a number, a delete, an object, and a string that is another value rather than an edit of it (`'todo'` → `'done'`, `'doing'` → `'done'`, a `<select>` changed twice). So checking and unchecking a to-do, or picking two statuses in a row, is two steps. To make several writes one step, run them in `edytor.transact(() => …)`.
- A patch with an empty `path` replaces the whole object (`patchData(id, [{ path: [], value: data }])`). `setBlock(id, { data })` (and Turn into) writes data as a `patchData` step, which hooks also see: one patch per property it names (`{ path: ['level'], value: 'h1' }`), never a replace.
- **A change of kind keeps the properties.** Turn into, a markdown shortcut, the slash menu, <kbd>Backspace</kbd> at the start of a heading or a to-do and `setBlock(id, { type, data })` set the properties the new kind's preset names and keep every other one, as Notion does: a checked red to-do turned into a heading keeps `checked` and `color` (a heading ignores them) and gains `level`; turned back into a to-do, the preset's `checked: false` unchecks it. A nested preset property sets its own keys inside an object the block holds there. To drop properties, delete them (`{ path: ['checked'] }`) or replace the object.
- Undo gives back exactly the properties the step changed; a collaborator's change to another property stays.

## How concurrent edits merge

Each leaf of the data (a string, number, boolean, `null`, an empty object or an empty array) is stored as its own entry, and each array item is its own entry with an id and a position, so:

- **Different properties** edited at the same time are all kept, at any depth: `title` and `done`, or `meta.a` and `meta.b`.
- **The same property** edited at the same time ends with one of the two values, the same on every replica (last writer wins).
- **Arrays merge item by item**, like a `Y.Array`. Edits inside different items are all kept (`tasks[0].title` and `tasks[1].done`), and so are both items of two concurrent pushes: concurrent inserts at the same place come in the same order on every replica, and what one person inserts right after an item they inserted in the same session stays right after it, even across separate writes (two `push` calls, two clicks on Add): a collaborator's insert made at that place before seeing them never lands between them. An insert lands between the items it was made between.
- **A removed item stays removed.** When one person removes an item while another edits inside it, the item is gone for both; the edit never brings it back.
- **A moved item keeps its edits.** `sort` and `reverse` move items: an edit made meanwhile inside one of them follows it to its new place, and an item removed meanwhile stays removed. Two people moving the same item at once leave it at one of the two places.
- **A whole array assignment keeps what stays.** `{ path: ['tasks'], value: next }` (or a replace of the object holding the array) keeps every item of the longest run that `next` keeps equal and in order; between two of them, old and new items pair up by position, each keeping its item and taking the new value; the rest are removed or inserted. So the immutable-update pattern (`tasks = [...tasks, task]`, `tasks.map(…)`, `tasks.filter(…)`) appends, edits or removes only those items, and a collaborator's edit inside the others is kept.
  - Two people reassigning the same array at once each pair their values with the same old items, so where both changed one, the last writer's value wins, and the items each inserted past them are both kept.
  - An array assigned where there was none (or only an empty one) gets items derived from its value: two people assigning the same one at once (a default every open view writes, `options ??= ['A', 'B']`) leave one copy, and two different ones are both kept, one after the other.
- **A whole assignment costs time linear in the array's length.** The items equal at the start and at the end are kept first; between them, the longest run is exact whenever the old items' values are all different (rows with their own ids or titles, `sort`'s order) or few of them repeat. When many values repeat (thousands of `true`/`false` flags, say), and more than 262,144 pairs of equal values would have to be compared, the items between the kept start and end pair up by position instead: each keeps its item and takes the new value at its index. Reassigning a 6,000-item array with one insert, one edit and one removal writes those three items and takes about 0.2 s in Node; set or splice one item (`tasks[i].done = true`, `tasks.splice(i, 1)`) when you know which one changed.
- **An object beats a value.** When one person sets `meta` to `5` while another sets `meta.b`, everyone ends with `meta` as an object: `{ b: … }`.
- **An atomic path is one value.** Key-by-key merging can mix two objects set at the same time: one person sets `link` to `{ url: a, title: A }` while another sets it to `{ url: b, kind: 'video' }`, and both end with `{ url: b, title: A, kind: 'video' }`, a link neither of them wrote. A kind that declares `atomic: ['link']` (in its block definition and in the document's `semantics`, so every replica agrees) stores `link` as one value: everyone ends with one of the two links, whole. A write inside it (`{ path: ['link', 'title'], value: 'X' }`) writes the whole `link` too, so it can lose to a concurrent write of `link` as a whole. Declare the paths whose parts only make sense together (a link, an embed's source); leave the rest per key.
- **A set beats a concurrent delete** of the same property, and a replace (of the whole object, or setting an object) removes only the properties its writer saw: a property a collaborator added meanwhile stays.
- Deleting the last property of an object keeps the object, empty.

For text that several people type into at once, use the block's content, which merges character by character.

## Headless

The document API has one operation for data: `document.patchData(target, ops)`, where `target` is a block id, `{ block, atom }` for an inline block, or `null` for the document. It takes the same patches as the operation (`document.patchData('a', [{ path: ['tasks'], splice: [0, 1] }])` removes the first task). The change report carries a block's new data in `meta`, an inline block's in the block's `content` runs, and the document's in `data` (see [the document API](/docs/reference/document-api)).

## Storage and upgrading

Upgrade every client of a document together: a client of an older version reads only the old whole-data attribute, and a client of the previous pre-release reads an array a current client wrote as an object keyed by its items' ids.

How properties are stored:

- **A leaf** is an attribute of the block, the inline block or the document, named `d/` and its path as a JSON pointer (`~` as `~0`, `/` as `~1`).
- **An array** is an attribute holding `[]` at its path, and its items. An item has a short random id (one derived from the array when the array is assigned whole where there was none), its value under `~` and its id (`d/tasks/~k3f9xq2a` for a string, `d/tasks/~k3f9xq2a/title` for a property of an object) and its position in `d/tasks/~k3f9xq2a#`, a sortable key. A move writes the new position in `d/tasks/~k3f9xq2a>`, so a concurrent removal still removes the item; a removed item has none of them.

How older documents are read:

- **Arrays written by the previous pre-release** are one attribute holding the whole array. Edytor reads them unchanged and keeps them while a write only adds, changes or moves items, splitting them into items at the first write that removes one (both peers then write the same items, so concurrent first writes merge as usual).
- **Documents written before properties were stored per leaf** store a block's data as one attribute. Edytor reads it unchanged, writes a set property over it, and splits it into leaves at the first write it would otherwise show through (a delete, an object replacing its object, a replace of the whole object).
- **Edge case.** When two people make such a first write to the same old block or array at the same moment, a property the other one set, or an item the other one removed, can come back.
