Plugin authors put independent fields in the plugin constructor. Apps and
registries finish a descriptor with one terminal .configure() call, including
component when it renders an ordinary node.
| Method | Use it for | Type behavior |
|---|---|---|
.extend() | Adapt an imported/prebuilt descriptor, use a constructor-inaccessible shared factory, or consume an earlier-stage type. | Widens the plugin type. |
.configure() | Override existing fields for one consumer. |
| Terminal and non-widening. |
definePlugin() and definePlugin() own every independent author
contribution:
| Field | Owns |
|---|---|
initialState | The seed for this plugin's editor-local store. |
api | Factory for immutable plugin-scoped services. |
read | Snapshot or transaction-local reads. |
selectors | Pure state-first derivations, including React subscriptions. |
update | Transaction-bound plugin mutations. |
| Editor fields | Read middleware, commands, corrections, state/effect/facet/selection descriptors, contributions, lifecycle, activation, and validation. |
codecs | Plugin-owned format decoding and encoding. |
Callbacks receive the inferred Plugin Context, including
editor, plugin, api, read, defineCodecs, store, and the active tx
inside update contributions.
Put independent fields and their context callbacks in the constructor. Use
.extend() for an imported/prebuilt declaration or types introduced by an
earlier stage.
import { definePlugin, usePluginStore } from 'platejs/react';
export type CounterPluginState = {
value: number;
};
export const CounterPlugin = definePlugin('counter', {
api: ({ store }) => ({
isEmpty: () => store.get('value') === 0,
}),
initialState: {
value: 0,
} satisfies CounterPluginState,
selectors: {
doubled: (state, factor: number) => state.value * factor,
isEven: (state) => state.value % 2 === 0,
},
update: ({ store, tx }) => ({
insertLabel: () => {
tx.text.insert(`Count: ${store.get('value')}`);
},
}),
});
export function CounterValue() {
const doubled = usePluginStore(CounterPlugin, 'doubled', 2);
const isEven = usePluginStore(CounterPlugin, 'isEven');
return (
<span>
{doubled} / {isEven ? 'even' : 'odd'}
</span>
);
}import { definePlugin, usePluginStore } from 'platejs/react';
export type CounterPluginState = {
value: number;
};
export const CounterPlugin = definePlugin('counter', {
api: ({ store }) => ({
isEmpty: () => store.get('value') === 0,
}),
initialState: {
value: 0,
} satisfies CounterPluginState,
selectors: {
doubled: (state, factor:
Let the constructor infer contributed API, read, selector, and update groups. Keep an exported state type only when package consumers need that contract.
After resolution, concrete editors expose the plugin API under its readable name. Generic package code uses the typed portal.
editor.api.counter.isEmpty();
editor.plugin(CounterPlugin).api.isEmpty();
editor.plugin(CounterPlugin).update.insertLabel();editor.api.counter.isEmpty();
editor.plugin(CounterPlugin).api.isEmpty();
editor.plugin(CounterPlugin).update.insertLabel();Inside a later update contribution, reuse an earlier update through the active transaction selector. Pass the capability name when the caller should not take a descriptor dependency:
export const CounterPairPlugin = CounterPlugin.extend(() => ({
update: ({ tx }) => ({
insertPair: () => {
tx.plugin(CounterPlugin.name).insertLabel();
tx.plugin(CounterPlugin.name).insertLabel();
},
}),
}));export const CounterPairPlugin = CounterPlugin.extend(() => ({
update: ({ tx }) => ({
insertPair: () => {
tx.plugin(CounterPlugin.name).insertLabel();
tx.plugin(CounterPlugin.name).insertLabel();
},
}),
}));The selector reuses the active transaction. Calling an editor portal one-shot update there would open a nested transaction.
Declare editor behavior directly on the plugin: schema, commands, corrections, read middleware, state/effect/facet/selection descriptors, contributions, lifecycle, activation, and validation.
import { definePlugin, editorCommands } from 'platejs';
export const SingleLinePlugin = definePlugin('singleLine', {
commands: ({ handle }) => [
handle(editorCommands.insertBreak, ({ state }) =>
state.transaction(() => {})
),
],
});import { definePlugin, editorCommands } from 'platejs';
export const SingleLinePlugin = definePlugin('singleLine', {
commands: ({ handle }) => [
handle(editorCommands.insertBreak, ({ state }) =>
state.transaction(() => {})
),
],
});Command and correction callbacks receive their declared capability context. When one needs
an earlier plugin-owned store, read, API, or update group, add one staged
.extend() and close over that capability. Do not pass owner context through a
new helper parameter:
import { definePlugin, editorCommands } from 'platejs';
export const TriggerPlugin = definePlugin('trigger', {
initialState: { enabled: true },
}).extend(({ store }) => ({
commands: ({ handle }) => [
handle(editorCommands.insertBreak, ({ state }) => {
if (!store.get('enabled')) return false;
return state.transaction(() => {});
}),
],
}));import { definePlugin, editorCommands } from 'platejs';
export const TriggerPlugin = definePlugin('trigger', {
initialState: { enabled: true },
}).extend(({ store }) => ({
commands: ({ handle }) => [
handle(editorCommands.insertBreak, ({ state }) => {
if (!store.get('enabled')) return false;
return state.transaction(() => {});
}),
],
}));For an independently reusable Plate descriptor, import
definePlugin from platejs and list the descriptor in
dependencies.
Plugin-specific and editor-wide host services share the root api channel;
Plate projects that API under the plugin name. Do not publish the same
implementation twice.
Use defineCommand from platejs when an action needs a typed, reusable intent
that handlers can intercept. Its builder returns false or a frozen
TransactionSpec. state.transaction(...) constructs that spec without
publishing; editor.update.command(descriptor, input) applies the handled spec
in one update.
Both state.transaction(...) and state.transaction.extend(...) callbacks
must finish synchronously. A returned Promise or thenable throws and discards
the draft. Complete asynchronous work before building the command.
Use handle(descriptor, handler) for fallback policy. Returning false runs the
next handler or descriptor default. Use around(descriptor, handler) when the
policy must rewrite downstream input or compose a prefix. An around handler
delegates only when it calls next() or next.after(prefix); returning false
rejects the command and stops fallback. next.after(prefix) evaluates downstream
behavior against the state produced by that prefix. Plugin order determines
handler order; the descriptor preserves input inference.
Build the MIME-keyed map with the callback's defineCodecs. This is the one
inline inference anchor for codec callbacks.
import { ContentSlice } from 'platejs';
import { definePlugin } from 'platejs';
export const RecordsPlugin = definePlugin('records', {
codecs: ({ defineCodecs }) =>
defineCodecs({
'application/json': {
scope: 'document',
decode: ({ data }) => ContentSlice.fromJSON(JSON.parse(data)),
encode: ({ slice }) => JSON.stringify(slice),
},
}),
});import { ContentSlice } from 'platejs';
import { definePlugin } from 'platejs';
export const RecordsPlugin = definePlugin('records', {
codecs: ({ defineCodecs }) =>
defineCodecs({
'application/json': {
scope: 'document',
decode: ({ data }) => ContentSlice.fromJSON(JSON.parse(data)),
encode: ({ slice }) => JSON.stringify(slice),
},
}),
});Use document scope only when the format represents the complete document.
Use defineCodecs(map) for self and product codecs. Use
defineCodecs(TargetPlugin, map) for a foreign codec; the helper injects the
target into every HTML rule. The map's 'text/html' value accepts one
schema-aware rule or a non-empty ordered rule tuple when the plugin owns
multiple HTML representations. Keep that tuple in the same map. Do not author
a direct codec map or add target manually.
Bind an ordinary node component in the same terminal .configure() call as
the descriptor's other consumer overrides.
import { ParagraphPlugin } from 'platejs/react';
import { ParagraphElement } from '@/components/editor/paragraph';
export const AppParagraphPlugin = ParagraphPlugin.configure({
component: ParagraphElement,
shortcuts: {
toggle: { keys: 'mod+alt+0' },
},
});import { ParagraphPlugin } from 'platejs/react';
import { ParagraphElement } from '@/components/editor/paragraph';
export const AppParagraphPlugin = ParagraphPlugin.configure({
component: ParagraphElement,
shortcuts: {
toggle: { keys: 'mod+alt+0' },
},
});Component binding preserves the plugin type. Do not assign the node component through a renderer registry field.
Use .configure() once, where an app or registry installs a plugin, to change
fields already declared by the author.
import { LinkPlugin } from 'platejs/react';
export const AppLinkPlugin = LinkPlugin.configure({
initialState: {
allowedSchemes: ['http', 'https', 'mailto'],
},
});import { LinkPlugin } from 'platejs/react';
export const AppLinkPlugin = LinkPlugin.configure({
initialState: {
allowedSchemes: ['http', 'https', 'mailto'],
},
});Object configs use Plate's merge rules: objects merge deeply, arrays replace,
and initialState shallow-merges. .configure() cannot publish a new capability and
must be the final call.
Configure a required dependency on its own descriptor and place that complete descriptor beside the owner in the app or registry plugin array.
type CellPluginState = {
padding: number;
};
const CellPlugin = definePlugin('cell', {
initialState: {
padding: 12,
} satisfies CellPluginState,
});
const GridPlugin = definePlugin('grid', {
dependencies: [CellPlugin],
});
export const AppGridPlugins = [
GridPlugin,
CellPlugin.configure({ initialState: { padding: 8 } }),
];type CellPluginState = {
padding: number;
};
const CellPlugin = definePlugin('cell', {
initialState: {
padding: 12,
} satisfies CellPluginState,
});
const GridPlugin = definePlugin('grid', {
dependencies: [CellPlugin],
});
export const AppGridPlugins = [
GridPlugin,
CellPlugin.configure({ initialState: { padding: 8 } }),
];Optional capabilities are ordinary array entries. Terminal configurations derived from the same authored plugin compose in array order: earlier fields survive unless a later configuration defines the same field. Unrelated plugins and divergent authoring branches cannot share a name.
Use toReactPlugin() in the owning React entrypoint to add React behavior to a reusable
headless plugin. App consumers do not insert a
conversion merely to set a component.
import { BaseScriptPlugin } from 'platejs';
import { EditorLeaf, toReactPlugin } from 'platejs/react';
export const ScriptPlugin = toReactPlugin(BaseScriptPlugin, {
component: (props) => (
<EditorLeaf
{...props}
as={props.leaf.script === 'sub' ? 'sub' : 'sup'}
>
{props.children}
</EditorLeaf>
),
});import { BaseScriptPlugin } from 'platejs';
import { EditorLeaf, toReactPlugin } from 'platejs/react';
export const ScriptPlugin = toReactPlugin(BaseScriptPlugin, {
component: (props) => (
<EditorLeaf
{...props}
as={props.leaf.script === 'sub' ? 'sub' : 'sup'}
>
{props.children}
</EditorLeaf>
),
});The package's React owner publishes ScriptPlugin; static/RSC code can bind a
server-safe component directly on BaseScriptPlugin without importing
platejs/react.