json-edit-react

June 29, 2026 ยท View on GitHub

screenshot

A highly-configurable React component for editing or viewing JSON/object data

๐Ÿš€๏ธ Explore the Demo

NPM Version GitHub License NPM Downloads

Ask DeepWiki Discuss on GitHub Sponsor

Features include:

  • โœ… Easy inline editing of individual values or whole blocks of JSON text
  • ๐Ÿ”’ Granular control โ€“ restrict edits, deletions, or additions per element
  • ๐Ÿ“ JSON Schema validation (using 3rd-party validation library)
  • ๐ŸŽจ Customisable UI โ€” built-in or custom themes, CSS overrides or targeted classes
  • ๐Ÿ“ฆ Self-contained โ€” plain HTML/CSS, no external UI library dependencies, and zero runtime dependencies
  • ๐Ÿ” Search & filter โ€” find data by key, value or custom function
  • ๐Ÿšง Custom components โ€” replace keys and/or values with specialised components (e.g. date picker, links, images, undefined, BigInt, Symbol)
  • ๐ŸŒ Localisation โ€” easily translate UI labels and messages
  • ๐Ÿ”„ Drag-n-drop re-ordering within objects/arrays
  • ๐ŸŽน Keyboard customisation โ€” define your own key bindings
  • ๐ŸŽฎ External control via callbacks and imperative methods

๐Ÿ’ก Try the Live Demo to see these features in action!

screenshot

Important

This documentation is for V2 of json-edit-react, which is currently in beta. V1 docs are here.

If you're upgrading from V1, be sure to read the migration guide.

๐ŸŽค๏ธ Got feedback? Open an issue, or join the discussion.

Optional Companion Packages


Contents

Using the editor (for end users)

It's pretty self explanatory (click the "edit" icon to edit, etc.), but there are a few not-so-obvious ways of interacting with the editor:

  • Double-click a value (or a key) to edit it
  • When editing a string, use Cmd/Ctrl-Enter or Shift-Enter to add a new line (Enter submits the value)
  • It's the opposite when editing a full object/array node (which you do by clicking "edit" on an object or array value) โ€” Enter for new line, and Cmd/Ctrl/Shift-Enter for submit
  • Escape to cancel editing
  • Use Tab/Shift-Tab to quickly move from one value to another when editing
  • When clicking the "clipboard" icon, holding down Cmd/Ctrl will copy the path to the selected node rather than its value
  • When opening/closing a node, hold down Alt/Option to open/close all child nodes at once
  • For Number inputs, the โ†‘ / โ†“ arrow keys will increment/decrement the value
  • For Boolean inputs, the Space bar will toggle the value
  • Easily navigate to the next or previous node for editing using the Tab/Shift-Tab keys.
  • Drag and drop items to change the structure or modify display order
  • When editing is not permitted, double-clicking a string value will expand the text to the full value if it is truncated due to length (there is also a clickable "..." for long strings)
  • JSON text input can accept "looser" input, if an additional JSON parsing method is provided (e.g. JSON5). See jsonParse prop.

Have a play with the Demo app to get a feel for it!

Installation

# Depending on your package manager:

npm i json-edit-react
# OR
yarn add json-edit-react
# OR
pnpm add json-edit-react

Implementation

import { JsonEditor } from 'json-edit-react'

// In your React component:
return (
  <JsonEditor
    data={ jsonData }
    setData={ setJsonData }
    { ...otherProps } />
);

// For a read-only viewer, use the `JsonViewer` component instead:
import { JsonViewer } from 'json-edit-react'

return <JsonViewer data={ jsonData } { ...otherProps } />

Props Reference

data and setData are the only required props. For a read-only component, use JsonViewer instead โ€” its only required prop is data.

This is a reference list of all possible props, divided into related sections. Most of them provide a link to a section below in which the concepts are explored in more detail.

Data Management

PropTypeDefaultDescription
dataobject|array-The data to be displayed / edited
setDataobject|array => void-Method to update your data object. Required. See Managing state below for additional notes.
onUpdateUpdateFunction-A function to run whenever a value is changed in the editor โ€” edit, add, delete, rename or move. See Update functions.
onChangeOnChangeFunction-A function to modify/constrain user input as they type โ€” see OnChange functions.
onErrorOnErrorFunction-A function to run whenever the component reports an error โ€” see OnErrorFunction.
showClipboardButtonbooleantrueShow or hide the "Copy to clipboard" button in the UI.
onCopyOnCopyFunction-A function to run whenever an item is copied to the clipboard โ€” see Copy Function.

Restricting Editing

PropTypeDefaultDescription
allowEditboolean|FilterFunctiontrueIf false, no editing at all is permitted. A callback function can be provided (return true to permit a given node) โ€” see Advanced Editing Control
allowDeleteboolean|FilterFunctiontrueAs with allowEdit but for deletion
allowAddboolean|FilterFunctiontrueAs with allowEdit but for adding new properties
allowTypeSelectionboolean|TypeOptions|TypeFilterFunctiontrueControls which data types the user can select, including Custom Node types, and Enums โ€” see Data Type Restrictions
newKeyOptionsstring[] | NewKeyOptionsFunction-New keys can be restricted to certain values โ€” see New Key Restrictions & Default Values
defaultValueany|DefaultValueFunctionnullValue that new properties are initialised with โ€” see New Key Restrictions & Default Values
allowDragboolean|FilterFunctionfalseSet to true to enable drag and drop functionality โ€” see Drag-n-drop

Look and Feel / UI

PropTypeDefaultDescription
themeThemeInputdefaultThemeEither one of the built-in themes (imported separately), or an object specifying some or all theme properties โ€” see Themes.
showIconTooltipsbooleanfalseDisplay icon tooltips when hovering.
indentnumber3Specify the amount of indentation for each level of nesting in the displayed data.
collapseboolean|number|FilterFunction3Defines which depth level of the data tree will be displayed "expanded" in the UI on initial load โ€” see Collapse.
collapseAnimationTimenumber300Time (in ms) for the transition animation when collapsing collection nodes.
collapseClickZonesArray<"left" | "header" | "property">["left", "header"]Aside from the โŒ„ icon, you can specify other regions of the UI to be clickable for collapsing/opening a collection.
rootNamestring"data"A name to display in the editor as the root of the data object.
showArrayIndexesbooleantrueWhether or not to display the index (as a property key) for array elements.
arrayIndexStart0 | 10The number the first array element's index label starts from.
showStringQuotesbooleantrueWhether or not to display string values in "quotes".
showCollectionCountboolean|"when-collapsed"|"when-collapsed-or-filtered""when-collapsed-or-filtered"Whether or not to display the number of items in each collection (object or array).
stringTruncateLengthnumber250String values longer than this many characters will be displayed truncated (with ...). The full string will always be visible when editing.
sortKeysboolean|CompareFunctionfalseIf true, object keys will be ordered (using default JS .sort()). A compare function can also be provided to define sorting behaviour, except the input type should be a tuple of the key and the value of a node i.e. (a: [string | number, ValueData], b: [string | number, ValueData]) => number
minWidthnumber|string (CSS value)250Minimum width for the editor container.
maxWidthnumber|string (CSS value)600Maximum width for the editor container.
baseFontSizenumber|string (CSS value)16pxThe "base" font size from which all other sizings are derived (in ems). By changing this you will scale the entire component.
insertAtTopboolean|"object"|"array"falseIf true, inserts new values at the top rather than bottom. Can set the behaviour just for arrays or objects by setting to "object" or "array" respectively.
errorDisplayTimenumber2500Time (in ms) to display the error message in the UI.
showErrorMessagesboolean trueWhether or not the component should display its own error messages (you'd probably only want to disable this if you provided your own onError function)

Search and Filtering

PropTypeDefaultDescription
searchTextstringundefinedData visibility will be filtered by matching against value, using the method defined below in searchFilter
searchFilter"key"|"value"|"all"|SearchFilterFunctionundefinedDefine how searchText should be matched to filter the visible items โ€” see Search/Filtering
searchDebounceTimenumber350Debounce time when searchText changes

Custom components & overrides (incl. Localisation)

PropTypeDefaultDescription
customNodeDefinitionsCustomNodeDefinition[]You can provide custom React components to override specific nodes in the data tree, according to a condition function โ€” see Custom nodes or browse the @json-edit-react/components
customButtonsCustomButtonDefinition[][]You can add your own buttons to the Edit Buttons panel if you'd like to be able to perform a custom operation on the data โ€” see Custom Buttons
translationsLocalisedStrings{ }UI strings (such as error messages) can be translated by passing an object containing localised string values (there are only a few) โ€” see Localisation
customTextCustomTextDefinitionsIn addition to localising the component text strings, you can also dynamically alter them, depending on the data โ€” see Custom Text
TextEditorReactComponent
ย ย <TextEditorProps>
Pass a component to offer a custom text/code editor when editing full JSON object as text. See details
SelectReactComponent
ย ย <SelectProps>
<NativeSelect>Pass a component to replace the built-in native <select> (drop-down)
jsonParse(input: string) => JsonDataJSON.parseProvide an alternative JSON parser (e.g. JSON5), to allow "looser" text input when editing JSON blocks.
jsonStringify(data: JsonData) => string(data) => JSON.stringify(data, null, 2)Similarly, override the presentation of the text when editing JSON. You can supply different formatting parameters to the native JSON.stringify(), or provide a third-party option, like the aforementioned JSON5.
keyboardControlsKeyboardControlsAs explained aboveOverride some or all of the keyboard controls โ€” see Keyboard customisation

External interaction

More detail below

PropTypeDefaultDescription
onEditEventOnEditEventFunction-Callback for the full edit lifecycle โ€” start/submit/commit/cancel, the instant delete/move, and the background updateSuccess/updateError settlement โ€” see Event callbacks
onCollapseOnCollapseFunction-Callback to execute whenever the user collapses or opens a node
editorRefRef<JsonEditorHandle>-Imperative handle to collapse/open nodes or start/stop editing. See Imperative handle

Miscellaneous

PropTypeDefaultDescription
idstring-Name for the HTML id attribute on the main component container.
classNamestring-Name of a CSS class to apply to the overall component. In most cases, specifying theme properties will be more straightforward.

Back to Contents

Managing state

Controlled component โ€” data + setData

You manage the data state yourself outside this component and pass in a setData method, which is called internally to update your data.

Keep the two channels distinct: data/setData are your local UI state (the document the editor renders and the setter it calls), while onUpdate is for external writes and side effects โ€” persisting to a server, validation, notifications, mutating-before-save. Don't reach for onUpdate to drive local state (that's setData's job), and don't treat raw setData calls as "saves": under the optimistic model an asynchronous onUpdate lets setData fire for a value that's still in flight or about to be reverted, so anything that must react only to settled changes (autosave, an undo stack, a dirty flag) should key off onUpdate or the updateSuccess/updateError events, not raw setData.

Read-only display โ€” JsonViewer

If your use case is read-only โ€” displaying JSON without any editing affordances โ€” import JsonViewer instead of JsonEditor:

import { JsonViewer } from 'json-edit-react'

<JsonViewer data={data} theme={someTheme} />

JsonViewer is a thin wrapper over JsonEditor that locks all edit, add, delete and drag operations off. It accepts the same display, theming, keyboard, search, collapse, localisation and custom-node props, but drops setData, the update callbacks (onUpdate / onChange), and the edit-permission props (allowEdit / allowAdd / allowDelete / allowDrag / allowTypeSelection) โ€” none of which are meaningful in a read-only context. Its editorRef handle (JsonViewerHandle) is collapse-only.

If you instead need an editor that sometimes locks editing (e.g. based on user permissions), keep using <JsonEditor> and toggle the relevant allow* props dynamically โ€” allowEdit={canEdit} etc.

โ–ถ Live example: Read-only viewer

Typed data โ€” JsonEditor<T>

JsonEditor is generic on the type of its data prop, so TypeScript users can preserve their data shape across the component boundary instead of falling back to unknown:

interface User {
  name: string
  email: string
  roles: string[]
}

const [user, setUser] = useState<User>(initialUser)

<JsonEditor<User>
  data={user}
  setData={setUser}
  onUpdate={({ newData }) => {
    // newData is typed as User
  }}
/>

The generic flows through data, setData, the onUpdate / onChange / onError callbacks (root data slots only โ€” per-node value stays unknown), and NodeData.fullData inside FilterFunctions. Defaults to JsonData (โ‰ˆ unknown) so untyped consumers don't need to change anything.

Tip

T describes the data you provide. It is an input contract, not a runtime invariant โ€” if the user can freely restructure the JSON, post-edit values may not conform to T. Pair with allowAdd / allowDelete / allowTypeSelection to lock the shape, or validate inside onUpdate if you depend on it.

Back to Contents

Filter Functions

// The powerhouse of the component
(nodeData) => result

The dynamic capabilities of the editor are powered by one core concept โ€” the FilterFunction. You provide a callback that receives a bunch of metadata about the current node, and it returns a boolean, or value, that determines what happens. Editing, search, styling, conditional rendering all rely on this workhorse structure, so we'll explain the shape of it here so you'll grasp how it's used throughout this doc.

TypeShapeDrivesSection
FilterFunction(nodeData) => booleanallowEdit / allowDelete / allowAdd / allowDrag, collapseControlling editing
TypeFilterFunction(nodeData) => boolean | DataType[]allowTypeSelectionControlling editing
SearchFilterFunction(nodeData, searchText) => booleansearchFilterSearch & filtering
StyleFunction(nodeData) => CSS | nulltheme stylesAppearance & theming
CustomTextFunction(nodeData) => string | nullcustomTextLocalisation
condition(nodeData) => booleancustomNodeDefinitionsCustom nodes & components
DefaultValueFunction(nodeData, newKey?) => valuedefaultValueControlling editing
NewKeyOptionsFunction(nodeData) => string[] | nullnewKeyOptionsControlling editing

The side-effect callbacks (onUpdate / onChange / onError / onCollapse / onCopy / onEditEvent) receive the same NodeData plus their own extras โ€” see Reacting to changes and Programmatic control.

The NodeData object

So what is this nodeData input? It's an object of the following shape:

interface NodeData {
    key: CollectionKey          // name of the property (string or number (arrays))
    path: CollectionKey[]       // path to the property (as an array of property keys)
    level: number               // depth of the property (with 0 being the root)
    index: number               // index of the node within its collection (based on display order)
    value: JsonData             // value of the property
    size: number | null         // if a collection (object, array), the number of items
                                //   (null for non-collections)
    visibleSize?: number | null // direct-child count under the current search filter.
                                //   - `number` on collections under an active filter;
                                //   - `null` on render-path nodes when no filter is active
                                //      or this isn't a tracked collection (e.g. a leaf).
                                //   - `undefined` only inside the `searchFilter` callback
                                //       itself (the walk hasn't computed counts yet) or when
                                //       NodeData reaches you via an imperative bridge
                                //       (onCollapse / onEditEvent / the editorRef handle).
                                //        Use `!= null` to gate on "has a real count".
    parentData: object | null   // parent object containing the current node
    fullData: JsonData          // the full (overall) data object
    collapsed?: boolean         // whether or not the current node is in a
                                //   "collapsed" state (only for Collection nodes)
}

Let's get into the many ways we can use this...

Back to Contents

Controlling editing

As well as configuring which nodes of can be edited, deleted, or added to, you can also specify:

  • the data types (if any) available to each node (including enums),
  • a restricted set of available keys that can be added to a node,
  • default values for specific nodes and data types,
  • drag 'n' drop restrictions
  • which nodes appear open or closed ("collapsed")

As outlined in the props list above, most of these props can take either:

  • a boolean (in which case true means the operation is fully permitted, false means it's fully disabled)
  • a FilterFunction callback, which allows edit controls to be defined dynamically

The callback for each type of permission is slightly different, so let's look at each in turn:

Permissions โ€” allowEdit / allowDelete / allowAdd

These each take a boolean value, or a FilterFunction callback, which must return a boolean -- if false that node will not be editable (return true to permit it).

โ–ถ Live example: Edit restrictions

Note

There is no specific permission for editing object property names. Renaming a property is equivalent to deleting it and re-adding it under a new name in its parent collection, so a key is editable when the node returns true for allowDelete and its parent collection returns true for allowAdd (and the node isn't the root or an array index). allowEdit plays no part โ€” a key can be renamed even where its value can't. This mirrors a drag-and-drop relocate: a delete here, an add there.

Restricting data types โ€” allowTypeSelection

The allowTypeSelection prop can take either a boolean (false means the data type can not be changed at all, true means any type is allowed) or a TypeFilterFunction, or an array of available data types. The core types are:

  • "string"
  • "number"
  • "boolean"
  • "null"
  • "object"
  • "array"

The data type array can also specify Custom Node types (as defined in the custom node's name prop), as well as Enum options (see Enums below).

The TypeFilterFunction, while it takes the same input shape as a standard FilterFunction, can return either a simple boolean or an array of available types.

Note

If allowTypeSelection returns less than two available types for a given node, the "Type Selector" drop-down won't appear for that node.

โ–ถ Live example: Type restrictions

Enums

By defining an Enum type, you can restrict the available values to a pre-defined list:

Eye colour enum example

To define an Enum, just add an object with the following structure to your "Types" array (either directly in the prop, or returned from the callback):

{
  enum: "My Enum Type" // name that will appear in the Types selector drop-down
  values: [  // the list of allowed values
    "Option A",
    "Option B",
    "Option C"
  ]
  matchPriority: 1 // (Optional) used to recognize existing string values
                   //   as the particular type (see below)
}

What is matchPriority? Well, when the data object is initialised, we have no way to know whether a given string value is "just a string" or is supposed to be one of the members of an Enum type (and we don't want to assume that if it's listed somewhere in an Enum values list that it definitely should be restricted to that type). So, if matchPriority is not defined, then that Enum type will never be initially assigned to a potentially matching Enum value when editing. If matchPriority is defined, then the highest priority Enum that has the value in its values list will be assigned (so if multiple Enums have overlapping values, the one with the highest priority will be applied.).

If the type of a given node is going to be restricted to a particular Enum type (i.e. the allowTypeSelection prop returns only one value), then a matchPriority is essential, otherwise it wouldn't be possible to switch a string to that type.

You can see examples of this in the Star Wars data set of the Demo โ€” the eye_color, skin_color, hair_color and films values are all restricted to a single, matching Enum type.

Note

When editing, once an Enum type is selected from the Types selector, that node will continue to be displayed as that type for subsequent edits in the same session -- the matchPriority is purely for automatic recognition of a given value as a specific type when first editing it.

โ–ถ Live example: Enums

New-key restrictions & default values

You can restrict the available properties a given collection node can have (when adding new properties) by setting the newKeyOptions prop. The value can be either a list of keys, or a NewKeyOptionsFunction callback which returns the key list.

This will cause the UI to present a Drop-down selector when adding a new key rather than the usual text input:

Key selection

The initial value for newly-added keys can also be defined with the defaultValue prop -- this can be any value, or a DefaultValueFunction callback returning any value. The input signature is almost the same as standard FilterFunctions, but it can take a second argument, which is the name of the new key.

You can see an example of this in the JSON Schema validation data of the Demo app when you add new keys to either the address collection or the root node.

โ–ถ Live example: New keys & defaults

Drag-and-drop reordering

The allowDrag property controls which items (if any) can be dragged into new positions. By default, this is off, so you must set allowDrag = true to enable this functionality. Like the Edit permissions above, this property can also take a FilterFunction for fine-grained control. There are a couple of additional considerations, though:

  • JavaScript does not guarantee object property order, so enabling this feature may yield unpredictable results. See here for an explanation of how key ordering is handled.

Warning

It is strongly advised that you only enable drag-and-drop functionality if:

  1. you're sure object keys will always be simple strings (i.e. not digits or non-standard characters)
  2. you're saving the data in a serialisation format that preserves key order. For example, storing in a Postgres database using the jsonb (binary JSON) type, key order is meaningless, so the next time the object is loaded, the keys will be listed alphabetically.
  • The allowDrag filter applies to the source node (the one being dragged) โ€” it controls whether a node can be picked up at all.
  • What a drop is allowed to do then depends on where it lands, and reuses your existing permission filters โ€” so if you've configured restrictive editing constraints with Filter functions, they can't be circumvented via drag-n-drop:
DropIs aโ€ฆRequires
Within the same collectionreorderallowEdit on that collection
Into a different collectionrelocateallowDelete on the source and allowAdd on the destination collection

A relocate is a delete-then-add across collections, so it needs both halves; a reorder changes nothing's membership, so it needs neither โ€” only that the collection itself is editable.

Back to Contents

Reacting to changes

onUpdate โ€” accept, reject, transform

The onUpdate prop allows you to provide a callback that runs whenever the data changes in the editor โ€” for every kind of change. You might wish to use this to update some external state, post a notification, make an API call, modify the data before saving it, or validate the data structure against a JSON schema. (It is not an alternative to setData โ€” see Managing state.)

The function receives two arguments. The first is a single object, built on the standard node data (key, path, value, fullData, etc.) with an event discriminant plus the change-specific fields; the second is a control object for gating the commit:

{
    // ...standard node data (key, path, value, fullData, ...)
    //    describing the node BEFORE the change โ€” for `add`, this is the new
    //    node's position, with `value` unset
    event,        // 'edit' | 'add' | 'delete' | 'rename' | 'move'
    newData,      // the whole document AFTER the change
    // ...plus one event-specific field:
    newValue,     // the new value           (event: 'edit' | 'add')
    newKey,       // the new key             (event: 'rename')
    newPath,      // the destination path    (event: 'move')
}

Branch on event to handle each operation:

onUpdate={(props) => {
  switch (props.event) {
    case 'edit':   /* props.newValue */ break
    case 'rename': /* props.newKey   */ break
    case 'move':   /* props.newPath  */ break
    case 'add':    /* props.newValue */ break
    case 'delete': break
  }
}}

The function can return nothing (the change proceeds as normal), or a value to accept/reject/transform/cancel the change. The return value can be one of:

  • true / void / undefined: the change proceeds as normal
  • false: treats the change as an error โ€” the data is not updated (reverts to the previous value) and a generic error message is displayed in the UI
  • null: silently cancels the change โ€” no update, and no error message (unlike false). Use this to quietly abort a change that isn't an error
  • { value: <value> }: replace the edited node's value with <value> (applied at its path). Use this to tidy what the user just entered โ€” lower-case a string, round a number, trim, sort this array. Honoured for edit and add; ignored for rename / move / delete (those events have no node value to set)
  • { data: <data> }: replace the whole document with <data>. Use this for cross-field changes โ€” stamping a lastModified, sorting sibling nodes, canonicalising the structure. Works for every event. (Returning both value and data is a mistake: data wins and value is ignored, with a console warning.)
  • { error: <string> } (or { error: { code, message } }): treats the change as an error, with your provided message shown in the UI

(Any of the above may also be returned from an async function / Promise.)

โ–ถ Live example: onUpdate basics

Async updates & gating โ€” hold()

Whether a commit is optimistic depends on your onUpdate. A synchronous onUpdate โ€” the typical case for local or JSON-Schema validation โ€” is resolved in place: a valid edit commits, and a rejected one (false, { error }, or a thrown error) is simply never applied, so the input reverts and the error shows with no transient write to your data. An asynchronous onUpdate (e.g. a network save) can't be known in time, so the commit is optimistic: the input closes and the data updates immediately, then onUpdate runs in the background and a rejection (including a rejected promise) is automatically reverted and the error surfaced. A slow onUpdate therefore never blocks the editor, and the user can keep working. Each in-flight commit is tracked independently, so a late failure reverts only its own node and can't clobber a newer edit.

Structural changes โ€” delete, drag-and-drop move, and adding an array item โ€” have no open input to keep responsive, so an asynchronous onUpdate waits a brief moment (~100ms) before applying optimistically: a result that arrives inside that window settles in place (the node is never removed/relocated, so a rejected delete keeps the node and shows its error immediately), and only a slower one falls back to apply-then-revert. A rejected move has no settled position to anchor an inline message to, so it reports through the onEditEvent updateError event rather than an inline error.

If instead you want to hold the editor open until a decision resolves โ€” e.g. to show a confirmation dialog, or to validate before the value is committed โ€” call hold() on the second argument:

onUpdate={async (props, { hold }) => {
  const release = hold()          // editor stays open; the rest of the tree is blocked
  const ok = await myConfirmDialog(props)
  if (!ok) return null            // abort โ€” the edit is discarded
  release()                       // commit now (closes the editor)
}}
  • hold() must be called synchronously, before the first await.
  • While held, the editor stays open and the rest of the tree can't be edited (one operation at a time).
  • release() applies the change and closes the editor.
  • If you hold() but never release(), the eventual onUpdate result decides โ€” the change commits when the promise resolves (unless it resolves to a reject/cancel). So a plain hold() โ†’ await โ†’ return keeps the editor open for the duration and then commits.

Tip

A common difficulty in React is getting a modal confirmation to await a decision. Modals are fundamentally declarative โ€” you render <Modal open={โ€ฆ} /> and respond to its button callbacks โ€” but an async onUpdate wants to ask imperatively ("did the user confirm?") and carry on with the answer. Bridging the two by hand means juggling a deferred promise, the dialog's open state, and core's synchronous hold() gate all at once.

@json-edit-react/utils does that bridging for you. useConfirmOnUpdate lets you declare when to confirm and what to say, and returns a ready-made onUpdate plus the dialog state to drive your own modal; the lower-level useJsonEditorConfirm hands back an awaitable confirm() (Promise<boolean>) for gating anything โ€” an editor edit, a toolbar action, a custom-node button. You still bring the modal; the hook owns the await.

โ–ถ Live example: Confirm & settle

onChange โ€” validating each keystroke

Similar to the Update function, the onChange function can be used to validate user input, except it's executed as the user types, not on submission. You can use this to restrict, constrain or transform user input โ€” e.g. limiting numbers to positive values, or preventing line breaks in strings. The function must return a value in order to update the user input field, so if no changes are to be made, just return the input value unmodified.

The input is the standard node data (key, path, value, fullData, etc.) with the in-progress newValue included in the object. (Since this runs before the data is committed, there's no newData โ€” value is the current value, fullData the current document.)

โ–ถ Live example: onChange validation

onError

Normally, the component will display simple error messages whenever an error condition is detected (e.g. invalid JSON input, duplicate keys, or custom errors returned by the onUpdate function). However, you can provide your own onError callback to capture the error data in order to implement your own error UI, or run additional side effects. (In the former case, you'd probably want to disable the showErrorMessages prop, too.) It receives the standard node data (key, path, value, fullData, etc.) with the following additional fields spread on top:

{
    // ...standard node data (key, path, value, fullData, ...)
    errorValue,   // the erroneous value that failed to update the property
    error: {      // a JerError
      code,       // one of 'UPDATE_ERROR' | 'DELETE_ERROR' | 'ADD_ERROR'
                  //   | 'RENAME_ERROR' | 'MOVE_ERROR' | 'INVALID_JSON' | 'KEY_EXISTS'
      message     // the (localised) error message that would be displayed
    }
}

โ–ถ Live example: Custom error UI

onCopy

The onCopy callback runs whenever an item is copied to the clipboard. It receives the standard node data (key, path, value, fullData, etc.) with the following additional fields spread on top:

{
    // ...standard node data (key, path, value, ...)
    type         // Either "path" or "value" depending on whether "Cmd/Ctrl" was pressed
    stringValue  // A nicely stringified version of the copied value
                 // (i.e. what the clipboard actually receives)
    success      // true/false -- whether the clipboard copy action actually succeeded
    error        // a JerError `{ code: 'CLIPBOARD_ERROR', message }`
                 //   present only when `success === false`
}

Tip

Since there is very little user feedback when clicking "Copy", a good idea would be to present some kind of notification (see Demo "Toast" notifications). There are situations (such as an insecure environment) where the browser won't actually permit any clipboard actions. In this case, the success property will be false, so you can handle it appropriately.

JSON Schema validation

It's possible to do full JSON Schema validation by creating an onUpdate that passes the data to a 3rd-party schema validation library (e.g. Ajv). This will then reject any invalid input, and display an error in the UI (or via a custom onError function). You can see an example of this in the Demo with the "JSON Schema Validation" data set (and the "Custom Nodes" data set).

โ–ถ Live example: JSON Schema validation

Back to Contents

Appearance & theming

Using a prebuilt theme (@json-edit-react/themes)

A selection of curated themes is available in the @json-edit-react/themes companion package (as seen in the Demo app). Install the package, then import a theme and pass it as the theme prop:

npm i @json-edit-react/themes
import { JsonEditor } from 'json-edit-react'
import { githubDarkTheme } from '@json-edit-react/themes'

const MyApp = () => {
  const [ data, setData ] = useState({ one: 1, two: 2 })

  return <JsonEditor
    data={data}
    setData={setData}
    theme={githubDarkTheme}
    // other props...
    />
}

Tip

If you've created a cool theme, feel free to submit a PR to include it in the themes package

Customising styles โ€” the theme object

However, you can pass in your own theme object, or part thereof. A theme object looks like the following (this is the "default" theme definition):

{
  displayName: 'Default',
  icons: { โ€ฆ },  // optional per-glyph IconDefinitions โ€” see "Icons" below
  styles: {
    container: {
      backgroundColor: '#f6f6f6',
      fontFamily: 'monospace',
    },
    // collection: {},
    // collectionInner: {},
    // collectionElement: {},
    // headerRow: {},
    // valueRow: {},
    // dropZone: {},
    property: '#292929',
    bracket: { color: '#002b36', fontWeight: 'bold' },
    itemCount: { color: '#0000004d', fontStyle: 'italic' },
    string: '#cb4b16',
    number: '#268bd2',
    boolean: 'green',
    null: { color: '#dc322f', fontVariant: 'small-caps', fontWeight: 'bold' },
    input: ['#292929'],
    inputHighlight: '#b3d8ff',
    error: { fontSize: '0.8em', color: 'red', fontWeight: 'bold' },
    iconCollection: '#002b36',
    iconEdit: '#2aa198',
    iconDelete: '#cb4b16',
    iconAdd: '#2aa198',
    iconCopy: '#268bd2',
    iconOk: 'green',
    iconCancel: '#cb4b16',
  },
}

The styles property is the main one to focus on. Each key (property, bracket, itemCount) refers to a part of the UI. The value for each key is either:

  • a string, in which case it is interpreted as the colour (or background colour in the case of container and inputHighlight)
  • a full CSS style object for fine-grained definition. You only need to provide properties you wish to override โ€” all unspecified ones will fallback to either the default theme, or another theme that you specify as the "base".
  • a Style Function, which is a variant of Filter Function tha takes the same input, but returns a CSS style object (or null). This allows you to dynamically change styling of various elements based on content or structure. (An example is in the Demo "Custom Nodes" data set, where the character names are styled larger than other string values)
  • an array combining any of the above. Static styles merge left โ†’ right (later wins per property); a "Style Function" always applies last, on top of the merged statics, and multiple functions compose in order. So you can pair static "fallback" styles with a conditional function โ€” when the function returns null it contributes nothing, leaving the statics showing through.

inputHighlight is the one exception to the above: it sets the text-selection colour through a ::selection rule (surfaced as a single CSS custom property), so it accepts only a colour string โ€” not a style object, function, or array.

For a simple example, you can take an existing theme and override just a few things โ€” pair static overrides (a fixed icon colour, bold-italic booleans) with a conditional style function, all pinned in place as you switch the base theme:

โ–ถ Live example: Theme overrides

Here's another cool use for Style Functions:

โ–ถ Live example: Heat map

So, to summarise, the theme prop can take either:

  • an imported theme, e.g "candyWrapperTheme"
  • a theme object:
    • can be structured as above with fragments, styles, displayName, icons (glyphs โ€” see Icons) etc., or just the styles part (at the root level)
  • any number of theme objects (each can be as full or minimal as you like) in an array (with later ones taking precedence when they overlap properties)

You can play round with live editing of the themes in the Demo app with the "Edit this theme!" data set.

Note

Sizing and scaling

Internally, all sizing and spacing is done in ems, never px (aside from the baseFontSize, which sets the "base" size). This makes scaling a lot easier โ€” just change the baseFontSize prop (or set fontSize on the main container via targeting the class, or tweaking the theme), and watch the whole component scale accordingly.

CSS classes

Another way to style the component is to target the CSS classes directly. Every element in the component has a unique class name, so you should be able to locate them in your browser inspector and override them accordingly. All class names begin with the prefix jer-, e.g. jer-collection-header-row, jer-value-string.

Note that theme styles are applied inline, so any property the theme sets takes precedence over your own CSS rules (short of !important). CSS-class overrides are therefore best for structural/layout tweaks the theme doesn't touch (spacing, sizing, borders); colours and fonts are best set through the theme prop.

Style fragments

A fragments object is a convenience to define named, reusable style tokens โ€” a colour or a snippet of CSS โ€” and reference them by name from any element's value. Think of it as a palette: define a value once and reuse it in several unrelated places, so a later tweak only happens in one spot.

fragments: { accent: '#E63946' },
styles: {
  property: 'accent',
  iconEdit: 'accent',
}

A fragment can also be a full style object, and can be mixed with extra properties (and other fragments) in an array:

fragments: { iconAdjust: { fontSize: '110%', marginRight: '0.6em' } },
styles: {
  iconEdit: ['iconAdjust', { marginLeft: '1em' }],
}

Icons

A theme owns its icon glyphs as well as their colour, though any icons not defined fall through as per the styles, bottoming out with the default icons.

The icons property is for defining the glyphs; the styles.icon... properties control the CSS that gets applied to them, though if an icon as specific colours applied to an inner path, these won't be overridden. The property is structure as follows:

interface ThemeIcons {
  add?: IconDefinition
  edit?: IconDefinition
  delete?: IconDefinition
  copy?: IconDefinition
  ok?: IconDefinition
  cancel?: IconDefinition
  collection?: IconDefinition
}

interface IconDefinition {
  content: React.ReactNode // the inner SVG markup โ€” <path>/<circle>/โ€ฆ (no outer <svg>)
  viewBox?: string // defaults to '0 0 24 24'
  svgProps?: React.SVGProps<SVGSVGElement> // extra <svg> attrs, e.g. a stroke icon: { fill: 'none', stroke: 'currentColor', strokeWidth: 2 }
  scale?: number // per-glyph size tweak (default 1)
}

Core renders the wrapping <svg> itself, so you supply only what goes inside it:

const myTheme = {
  icons: {
    add: { content: <path d="M13 7h-2v4H7v2h4v4h2v-4h4v-2h-4z" /> },
  },
  styles: { iconAdd: '#2aa198' }, // colours the glyph
}

Colour follows currentColor. Core applies the theme's icon colour to the <svg>, so any glyph path that uses fill="currentColor" (or sets no fill) adopts it. A path with its own explicit fill keeps that colour โ€” so multi-colour glyphs (flags, brand logos) survive theming, as long as every coloured path carries its own fill.

Sizing. Icons render a little larger than text by default; scale is a per-glyph multiplier on that baseline (e.g. scale: 1.3 renders 30% bigger). Use it only to even out a glyph whose artwork over- or under-fills its viewBox โ€” size lives in the glyph, never in styles.

Pasting raw SVG. The iconFromSvg helper in @json-edit-react/utils turns a raw SVG string (or a React <svg> element) into an IconDefinition, so you can drop a copied icon straight in:

import { iconFromSvg } from '@json-edit-react/utils'

const myTheme = {
  icons: { add: iconFromSvg('<svg viewBox="0 0 24 24" fill="currentColor"><path d="M13 7h-2v4H7โ€ฆ"/></svg>') },
  styles: {},
}

To replace an icon of another theme, layer the new icon onto the theme array โ€” the same mechanism as style overrides:

theme={[githubDarkTheme, { icons: { add: iconFromSvg('<svgโ€ฆ>') } }]}

Caution

A string passed to iconFromSvg is interned โ€” identical markup returns the same IconDefinition object every time โ€” so the result is referentially stable and safe to write inline, like in this example. The other forms aren't: a React element, a pre-built IconDefinition, or a raw React node placed directly in theme.icons produces a new object on every render. Define that value outside the component, or wrap it in useMemo (the same rule as any inline theme value), so its changing reference doesn't churn the editor's re-rendering.

โ–ถ Live example: Custom icons

The bundled stylesheet (Shadow DOM)

The component's base stylesheet is bundled in and injected into the document <head> automatically, so in the normal case there's nothing to import โ€” styling works out of the box.

The exception is when the editor renders inside a Shadow DOM: styles injected into the document <head> don't cross the shadow boundary, so the component renders unstyled. For this case the stylesheet is also published as a standalone file you can import and inject into the shadow root yourself:

import 'json-edit-react/style.css'

How that import resolves depends on your bundler โ€” most will inline or extract it so you can attach it where you need it (for example via a <style> element inside the shadow root, or by adding a constructed stylesheet to shadowRoot.adoptedStyleSheets). The stylesheet defines its custom properties on both :root and :host, so it applies correctly whether it lives in the document or in a shadow root.

Back to Contents

Initial expansion โ€” collapse

The collapse prop determines at what level the tree is expanded to when initially loading:

  • false: fully open, all nodes expanded all the way down
  • true: fully collapsed to the root
  • a number: expand to a depth of this number, anything more nested that that remains collapsed until manually opened. (Default: 3). This is handy for really big data sets โ€” nodes that are closed when initialised never get rendered (so none of their children even mount) until they are expanded.
  • FilterFunction: dynamically control what is open based on node data (see FilterFunctions) โ€” handy if you want to pull your users' focus to a certain branch of the tree, say. (The live Guestbook uses a collapse filter function to highlight the first and last entries, as well as the guidance text. )

Note

The tree reacts to a changing collapse prop โ€” whenever it changes, the full tree resets to the collapse state determined by the new collapse value (or per-node FilterFunction result)

Back to Contents

Search & filtering

Basic search โ€” searchText + searchFilter

The displayed data can be filtered based on search input from a user. The user input should be captured independently (we don't provide a UI here) and passed in with the searchText prop. This input is debounced internally (time can be set with the searchDebounceTime prop). The values that the searchText are tested against is specified with the searchFilter prop. By default (no searchFilter defined), it will match against the data values (with case-insensitive partial matching โ€” i.e. input "Rod", will match value "Frodo").

Custom search โ€” SearchFilterFunction

You can specify what should be matched by setting searchFilter to either "key" (match property names), "value" (the default described above), or "all" (match both properties and values). This should be enough for the majority of use cases, but you can specify your own SearchFilterFunction. The search function is the same signature as the above FilterFunctions but takes one additional argument for the searchText, i.e.

( { key, path, level, value, ...etc }:FilterFunctionInput, searchText:string ) => boolean

There are two helper functions (matchNode() and matchNodeKey()) exported with the package that might make creating a search function easier (these are the functions used internally for the "key" and "value" matches described above). You can see what they do here.

An example custom search function can be seen in the Demo with the "Client list" data set โ€” the search function matches by "name" and "username", and makes the entire "Client" object visible when one matches, so it can be used to find a particular person and edit their specific details.

โ–ถ Live example: Client list

Tip

searchFilter callbacks like the one shown here can be fiddly to write, so we've provided a filter-function toolkit in @json-edit-react/utils โ€” a set of small, composable predicate builders (byKey, byPath, byLevel, byType, byValue), position constants (root, collections, primitives), combinators (and / or / not), and search bridges (matchRecord, matchesSearch) that snap together to build both searchFilter and the allow* editing props. The matchRecord shown above is one of them, and because every builder is interned, you can write them inline โ€” no useMemo โ€” without defeating json-edit-react's fine-grained re-rendering.

Back to Contents

Localisation

translations

Localise your implementation (or just customise the default messages) by passing in a translations object to replace the default strings. The keys and default (English) values are:

{
  ITEM_SINGLE: '{{count}} item',
  ITEMS_MULTIPLE: '{{count}} items',
  ITEMS_FILTERED: '{{visible}} of {{total}} items',
  KEY_NEW: 'Enter new key',
  KEY_SELECT: 'Select key',
  NO_KEY_OPTIONS: 'No key options',
  ERROR_KEY_EXISTS: 'Key already exists',
  ERROR_INVALID_JSON: 'Invalid JSON',
  ERROR_UPDATE: 'Update unsuccessful',
  ERROR_DELETE: 'Delete unsuccessful',
  ERROR_ADD: 'Adding node unsuccessful',
  ERROR_RENAME: 'Rename unsuccessful',
  ERROR_MOVE: 'Move unsuccessful',
  DEFAULT_NEW_KEY: 'key',
  SHOW_LESS: '(Show less)',
  EMPTY_STRING: '<empty string>' // Displayed when property key is ""
  // These label the icon controls (which are <button>s) for assistive tech via
  // `aria-label`, and also show as visible tooltips when the `showIconTooltips`
  // prop is enabled.
  TOOLTIP_COPY: 'Copy to clipboard',
  TOOLTIP_EDIT: 'Edit',
  TOOLTIP_DELETE: 'Delete',
  TOOLTIP_ADD: 'Add',
  TOOLTIP_OK: 'OK',
  TOOLTIP_CANCEL: 'Cancel',
}

Your translations object doesn't have to be exhaustive โ€” only define the keys you want to modify.

โ–ถ Live example: Localisation

Dynamic text โ€” customText

It's possible to change the various text strings displayed by the component. You can localise it, but you can also specify functions to override the displayed text based on certain conditions. For example, say we want the property count text (e.g. 6 items by default) to give a summary of a certain type of node, which can look nice when collapsed. For example (taken from the Demo):

Custom text example

The customText property type is:

type CustomTextDefinitions = Partial<{ [key in keyof LocalisedStrings]: CustomTextFunction }>

// i.e.
// {
//   ITEM_SINGLE: (nodeData) => string | null,
//   ITEMS_MULTIPLE: (nodeData) => string | null
//   ...etc. for other keys as desired
// }

โ–ถ Live example: Custom text

Back to Contents

Keyboard control

The default keyboard controls are outlined above, but it's possible to customise/override these. Just pass in a keyboardControls prop with the actions you wish to override defined. The default config object is:

{
  confirm: 'Enter',  // default for all Value nodes, and key entry
  cancel: 'Escape',
  objectConfirm: { key: 'Enter', modifier: ['Meta', 'Shift', 'Control'] },
  objectLineBreak: 'Enter',
  stringConfirm: 'Enter',
  stringLineBreak: { key: 'Enter', modifier: 'Shift' },
  numberConfirm: 'Enter',
  numberUp: 'ArrowUp',
  numberDown: 'ArrowDown',
  tabForward: 'Tab',
  tabBack: { key: 'Tab', modifier: 'Shift' },
  booleanConfirm: 'Enter',
  booleanToggle: ' ', // Space bar
  clipboardModifier: ['Meta', 'Control'],
  collapseModifier: 'Alt',
}

If (for example), you just wish to change the general "confirmation" action to Cmd-Enter (on Mac), or Ctrl-Enter, you'd just pass in:

  keyboardControls = {
    confirm: {
      key: "Enter",
      modifier: [ "Meta", "Control" ]
    }
  }

Considerations:

  • Key names come from this list
  • Accepted modifiers are "Meta", "Control", "Alt", "Shift"
  • On Mac, "Meta" refers to the Cmd key, and "Alt" refers to Option
  • If multiple modifiers are specified (in an array), any of them will be accepted (multi-modifier commands not currently supported)
  • You only need to specify values for stringConfirm, numberConfirm, and booleanConfirm if they should differ from your confirm value โ€” they inherit whatever you set confirm to, including null.
  • To disable a control entirely, set it to null. The key is then no longer intercepted and falls through to its native browser behaviour. This is useful when the default bindings aren't appropriate for your data โ€” for example, { tabForward: null, tabBack: null } turns off Tab navigation between editable nodes so that Tab/Shift-Tab resume their normal focus-traversal behaviour. Because the per-type confirms inherit from confirm, setting confirm: null disables Enter-to-submit across string, number, boolean and null value editors at once (object/array nodes use objectConfirm, so disable that separately if needed). The two modifier controls, clipboardModifier and collapseModifier, can likewise be disabled with null or an empty array [].
  • You won't be able to override system or browser behaviours: for example, on Mac "Ctrl-click" will perform a right-click, so using it as a click modifier won't work (hence we also accept "Meta"/"Cmd" as the default clipboardModifier).
Back to Contents

Custom Nodes & Components

Custom nodes are a powerful way to extend the functionality of json-edit-react to integrate any kind of data structure you can imagine into the editor. They're built around two simple properties:

  • condition โ€” what nodes get treated as "special"
  • component โ€” what we render those nodes with

See the "Custom Nodes" data set in the demo to see a few in action.

A wide range of pre-build custom components are available in a separate package to import and drop in as required. These include hyperlinks, date pickers, color pickers, image renderers and several "non-JSON" data types (undefined, BigInt, Symbol, etc.)

Browse them all at @json-edit-react/components, and see most of them in use on the demo site's "Custom Component Library".

โ–ถ Live example: Custom component library

Tip

Feel free to contribute any custom components you've made that you think would be useful to others

How to use the pre-built components

Details for each specific component are available on that package's README, but the general pattern is the same: import the component's definition factory, call it, and pass the result into the customNodeDefinitions prop. For example, the Hyperlink component โ€” which turns URL strings into clickable links โ€” is added like this:

import { JsonEditor } from 'json-edit-react'
import { hyperlinkDefinition } from '@json-edit-react/components'

// Define once (module scope or `useMemo`) so the reference stays stable
const customNodeDefinitions = [hyperlinkDefinition()], //add other definitions in the array

const App = () => (
  <JsonEditor data={data} setData={setData} customNodeDefinitions={customNodeDefinitions} />
)

Each factory accepts an optional options object to customise its behaviour โ€” e.g. hyperlinkDefinition({ condition: ({ key }) => key === 'homepage' }) to restrict it to a specific field โ€” and falls back to sensible defaults when called with no arguments.

That condition doesn't replace the definition's built-in one โ€” it's AND-ed with it. Each pre-built definition already recognises the data it handles (URL strings for Hyperlink, ISO dates for DatePicker), so a condition you supply only narrows where the component applies. This AND-ing is exactly why the definitions are exposed as factory functions.

Writing a custom node definition โ€” condition + component

Custom nodes are provided in the customNodeDefinitions prop, as an array of objects of following structure:

{
  condition,            // a FilterFunction, as above

  // The two render slots โ€” provide either, both, or neither:
  keyComponent,         // React component โ€” renders in the KEY slot (the property label)
  component,            // React component โ€” renders in the VALUE / contents slot

  componentProps,       // object (optional) โ€” props shared by `keyComponent` and `component`
  showKey,              // boolean (optional), default true
  defaultValue,         // value (or a () => value function) for a new instance
  
  // Components are "display only" by default (falls back to core UI for editing)
  showOnEdit            // boolean, default false
  showOnView            // boolean, default true
  showEditTools         // boolean, default true
  name                  // string (appears in Type selector)
  showInTypeSelector    // boolean (optional), default false
  editOnTypeSwitch      // boolean (optional), default false -- switching to this type opens
                        // it for editing instead of committing defaultValue instantly
  passOriginalNode      // boolean (optional), default false -- if `true`, makes the original
                        // node available for rendering within the custom node
  
  // Only affects Collection nodes:
  showCollectionWrapper // boolean (optional), default true
  wrapperComponent      // React component (optional) to wrap *outside* the normal collection wrapper
  wrapperProps          // object (optional) -- props for the above wrapper component
  renderCollectionAsValue // For special "object" data that should be treated like a "Value" node

  // For JSON conversion -- only needed if editing as JSON text
  stringifyReplacer    // function for stringifying to JSON (if non-JSON data type)
  parseReviver?:       // function for parsing as JSON (if non-JSON data type)

  // For type switching & editing
  toStandardType       // function to convert the custom value to a primitive when the
                       // Type selector switches this node to a standard type
  fromStandardType     // the inverse: function to convert a standard-typed value into this
                       // type's value โ€” runs when the user confirms an edit, and to seed
                       // the editor on an `editOnTypeSwitch` switch (see below)
}

A definition has two essential parts: a condition that decides which nodes it applies to, and a component that decides what renders in their place. condition is just a Filter function โ€” it receives the standard nodeData (key, path, value, etc.) and returns a boolean. Every node in the tree is tested against each definition in turn, and the first one whose condition matches wins, so order your customNodeDefinitions array by priority.

That's the whole model. Everything below is optional depth you add only when you need it.

The two slots: key and value

Every node is rendered as a key (the property label) and a value (everything to the right of it). A definition can target either slot, independently:

  • component replaces the value โ€” the common case (an image, a date picker, a colour swatch).
  • keyComponent replaces the key โ€” a styled, annotated, or interactive label.
  • Provide both to own the entire row.

Reach for the specific slot you want before reaching for a whole-node override: showKey: false lets one component render the whole row, but it's an escape hatch for tightly-coupled composites that genuinely can't be split โ€” and it gives up the standard key-editing UX.

Collection (object/array) nodes use these same two slots, plus a couple of collection-only options โ€” see Collection nodes below.

What your component receives

Your component gets all the props a built-in node gets, plus a few extras โ€” see BaseNodeProps (common to every node) and CustomComponentProps. The ones you'll use most:

  • value โ€” the node's current value.
  • setValue(newValue) โ€” commit a change from inside your component.
  • nodeData โ€” the full node data (key, path, parentData, โ€ฆ).
  • componentProps โ€” the custom props your component receives โ€” your own config, like props you'd pass to any React component โ€” regardless of whether it sits in the component or keyComponent slot.
  • isPending โ€” true while this node's optimistic edit is still settling (an async onUpdate hasn't resolved yet) โ€” drive a spinner or overlay off it.

A worked example with both standard and custom props: see the Date Picker component source (in @json-edit-react/components) for the props read from inside a component, or the focused example below for a custom componentProps config wired from the outside.

โ–ถ Live example: Date picker

Display vs. edit modes

By default a custom component is a display component: it renders in the viewer, and editing falls back to the standard interface. Three flags change that:

  • showOnView (default true) โ€” render in view mode.
  • showOnEdit (default false) โ€” render in edit mode too. Set this for a component that is its own editor (a date picker, a colour picker).
  • showEditTools (default true) โ€” show the copy / add / edit / delete icons on hover. Disable them only if your component supplies its own way to enter edit mode.

โ–ถ Live example: Display vs. edit modes

Editing a non-plain value โ€” fromStandardType

If your component edits a value whose committed form isn't the raw text in the edit buffer โ€” say the buffer holds digits but the value is a BigInt โ€” define fromStandardType: (value, nodeData, componentProps) => value, the inverse of toStandardType: it takes a standard-typed value and returns your custom one. It fires at two moments โ€” when an edit is confirmed (the input is the edit buffer; the โœ“ button, Enter, Tab, editorRef.confirm()) and when a type switch seeds your component (the input is the node's current value, demoted to a standard type first). Pass already-correct values through unchanged.

throw to signal an unconvertible value. On a confirm that rejects the edit โ€” nothing commits, the editor stays open with the user's text intact, and the message shows inline and fires onError (the same as confirming invalid JSON). On a type-switch seed there's nothing to reject yet, so it simply falls back to seeding defaultValue (the original stays recoverable with Esc).

โ–ถ Live example: BigInt

Letting users create your type

To let users turn a node into your custom type from the Type selector, set showInTypeSelector: true and provide:

  • name โ€” the label shown in the selector.
  • defaultValue โ€” the value inserted when the type is chosen. It must satisfy your condition (so the new node immediately renders as your component). It can also be a function (nodeData) => value, called each time the type is chosen โ€” use this for a fresh value like () => new Date() rather than one fixed when your module first loads.

By default, choosing the type commits defaultValue and closes the editor. For types the user will almost always want to edit straight away (date, colour, BigInt), set editOnTypeSwitch: true (requires component + showOnEdit): the edit buffer is seeded by your fromStandardType (so a stringโ†’Symbol switch carries the string into the description), falling back to defaultValue; your component opens in its edit state, one commit happens on confirm, and Esc cancels the whole switch.

โ–ถ Live example: Creating custom types

Customising the key โ€” keyComponent

A keyComponent replaces the property label. The key difference from a value component: a value component can host its own editor (showOnEdit + setValue), but a keyComponent renders in view mode only โ€” so it handles editing by delegating back to the standard key input through a few handles, rather than rendering its own editor (which it never needs โ€” a key is only ever a string or number).

The props you'll reach for (CustomKeyProps has the full set):

  • name โ€” the key as displayed (array indices already offset by arrayIndexStart, empty keys already substituted with the emptyStringKey placeholder). For the raw key, use nodeData.key.
  • nodeData โ€” the full node data.
  • componentProps โ€” the same config object passed to component.

And, since the component itself is view-only, three handles to let the user rename the key:

  • canEditKey โ€” whether key editing is permitted (gates the two below).
  • startEditingKey() โ€” hand off to the standard key input.
  • handleEditKey(newKey) โ€” commit a new key programmatically.

Plus a handful of layout/interaction extras โ€” styles / getStyles (theme styles; spread ...styles to keep column alignment), handleClick (forward it for default behaviour like collapse-on-header-click), and path โ€” see CustomKeyProps for the full list.

keyComponent works identically on value and collection nodes, so one definition can, say, give every underscore-prefixed key a lock icon whether its value is a primitive or a nested object. The same definition can combine keyComponent and component to own a row end-to-end.

Warning

The colon after the key is not rendered for you โ€” your component owns the entire key slot. And showKey: false suppresses the key slot completely, including any keyComponent.

โ–ถ Live example: Custom node keys

Decorating the default node โ€” passOriginalNode

Sometimes you don't want to replace a node, just add to it. Set passOriginalNode: true and your component also receives originalNode and originalNodeKey โ€” the value and key exactly as the library would have rendered them. Render those plus your decoration (a badge, a marker, a highlight) for a "default node, with extra" effect. The ErrorIndicator component works this way. (You may need a little CSS to line your wrapper up with the default layout.)

โ–ถ Live example: Decorating nodes

Collection nodes

Object and array nodes use the same two slots as value nodes, plus options for the parts a value node doesn't have. The full mapping across both:

SlotValue nodeCollection node
KeykeyComponentkeyComponent (brackets, chevron, count, collapse all preserved)
Value / contentscomponentcomponent (renders between the brackets)
Whole nodecomponent + showKey: falsewrapperComponent (+ wrapperProps), or showCollectionWrapper: false

The collection-specific details:

  • When your component takes over the contents, the normal descendants are handed to you as React children โ€” rendering them is now your job.

  • wrapperComponent wraps the collection from the outside: the entire collection node โ€” brackets, chevron, count, and its contents (including any custom component) โ€” arrives as your wrapper's children, so you render {children} where the collection should sit. The wrapper is optional โ€” a component on its own renders inside the default brackets. Below, both slots are on the same node: the blue border is the wrapperComponent, and the red is its component rendering the contents โ€” nested inside, since the component is part of what the wrapper gets as children (note the key and brackets sit inside blue but outside red):

    custom node levels
  • showCollectionWrapper: false is the full-replacement escape hatch โ€” no chevron, brackets, or built-in collapse, so you're responsible for completely rendering the data within.

  • With showOnEdit: true your component owns the node's editor, so it keeps receiving the live child rows as children while editing too (the same as in view) rather than the built-in JSON textarea. It supplies its own commit affordance โ€” setIsEditing to open the session, handleEdit / handleCancel to close it โ€” and edits the collection through setValue. This lets a node compose an editable header or toolbar above rows that stay visible and interactive throughout the edit.

See the different "wrapper" and "inner" component elements in use:
โ–ถ Live example: Custom collection nodes

A "full-takeover" example:
โ–ถ Live example: Student ID cards

A showOnEdit collection whose header/toolbar stays live above editable rows:
โ–ถ Live example: Playlist

Displaying a collection as a value

For a specialised object you'd rather treat as a single value โ€” a JavaScript Date, say โ€” set renderCollectionAsValue: true. The whole object is passed to your component as one value instead of being expanded into key/value rows; your component is responsible for handling it. The DateObject and EnhancedLink components in @json-edit-react/components both do this.

โ–ถ Live example: Collection as a value

Editing as JSON โ€” stringifyReplacer / parseReviver

If your node holds a non-JSON value (BigInt, Date, Symbol, โ€ฆ), editing the document as raw JSON text would lose it to the default JSON.stringify / JSON.parse. Supply a replacer and reviver to serialize and restore it however you like.

This is separate from fromStandardType: that handles the inline edit buffer (field editing and type-switching), whereas this handles JSON text. A non-JSON type like BigInt typically needs both, as seen in the example above.

The BigInt component, for example, is represented in JSON text as:

{
  "__type": "BigInt",
  "value": 1234567890123456789012345678901234567890
}

which can then be re-parsed into a true BigInt when committing.

Back to Contents

Overriding and extending the UI

json-edit-react aims to be unopinionated about UI implementations, so as well as being extremely style-able, you can also swap out basic UI elements with your own, as well as adding your own custom buttons to the edit tools.

Two UI elements ("widgets") are easily swappable โ€” you just have to provide an alternative component that complies with the same API surface:

  • textarea โ€”ย used for editing JSON blocks, passed in on the TextEditor prop
  • select โ€” drop-down selectors, passed in on the Select prop

โ–ถ Live example: Swap the built-ins

Replacing the text/code editor โ€” TextEditor (CodeEditor)

By default, this is a native HTML textarea element for plain-text editing. You can replace it with any component that offers the following API:

  • value: string โ€” the current text
  • onChange: (value: string) => void โ€” should be called on every keystroke to update value
  • onKeyDown: (e: React.KeyboardEvent) => void โ€” should be called on every keystroke to detect "Accept"/"Cancel" keys

You can see an example in the demo where I have implemented CodeMirror when the "Custom Text Editor" option is checked. It changes the native editor (on the left) into the one shown on the right:

Text editor comparison

This demo component is available from the @json-edit-react/components package

Tip

True JSON text is rather fussy about formatting (quoted keys, no trailing commas, etc.), which can be annoying to deal with when typing by hand. I recommend accepting "looser" JSON text input by passing in an alternative parser, such as JSON5 (which is what is used in the Demo). Set this via the jsonParse prop.

Replacing the native <select> element

Similarly, the drop-downs used in component are stock HTML select elements. Drop-downs can appear in 3 places:

You can provide any component you like that conforms to the following API:

export interface SelectProps {
  options: string[]
  /** Controlled value. Mutually exclusive with `defaultValue`. */
  value?: string
  /** Initial value when used uncontrolled โ€” typically `''` so the
   *  placeholder shows first. Mutually exclusive with `value`. */
  defaultValue?: string
  /** Fired with the selected option's value. */
  onChange: (value: string) => void
  /** Forwarded to the underlying input. Lets the call site keep
   *  ownership of keyboard semantics via `handleKeyboard(...)`. */
  onKeyDown?: (e: React.KeyboardEvent) => void
  /** Grab focus on mount. */
  autoFocus?: boolean
  /** Disabled first option shown when nothing is selected. */
  placeholder?: string
  /** Form name / id hint. */
  name?: string
  /** Class applied to the inner control. */
  className?: string
}

If your drop-down component of choice has a different interface, you can create a thin wrapper component around it to translate the props accordingly. I have provided an example of this using react-select in the @json-edit-react/components repo, called ReactSelect, so you can copy that pattern or just use that one directly (this is the component used in the example above).

But what about the text input element?

I haven't made the main string input component swappable, for two main reasons:

  1. It's actually a special textarea that grows in width and height as the contents changes, not just a single-line <input>. I'm using a bit of a hack to achieve this, so it wouldn't be straightforward to just swap it out with a single component that could directly replace it.
  2. Unlike <select>, which has genuinely useful functionality differences in other offerings, the text input is intended to be minimal, so you can achieve (almost) the full look as any UI library just using CSS (Details for specific libraries forthcoming)/

Custom buttons

In addition to the "Copy", "Edit" and "Delete" buttons that appear by each value, you can add your own buttons if you need to allow some custom operations. Provide an array of button definitions in the customButtons prop, with the following structure:

customButtons = [
  {
    Element: React.FC<{ nodeData: NodeData }>,
    onClick?: (nodeData: NodeData, e: React.MouseEvent) => void
  }
]

Warning

The onClick is optional -- don't provide it if you have your own onClick handler within your button component.

Note

Unlike custom node definitions, custom buttons don't have a condition property. However, you can still make them conditional as they have full access to each node's nodeData โ€”ย just return null from the component when they shouldn't appear.

โ–ถ Live example: Custom buttons

Back to Contents

Programmatic control

You can interact with the component externally, with event callbacks and triggers to set/get the collapse or editing state of any node.

Listening to the lifecycle โ€” onEditEvent

Pass in a function to the props onEditEvent and onCollapse if you want your app to be able to respond to these events.

The onEditEvent callback streams the complete interaction lifecycle. It receives the standard node data (key, path, value, fullData, โ€ฆ) with an event field โ€” the current step โ€” spread on top. Each kind of change emits its own events: editing a value, renaming a key and adding a property run as multi-step sessions, while deleting, moving, and the background result of a save each fire a single event.

ChangeEvents (in order)Notes
Edit a valuestartEdit โ†’ submitEdit โ†’ commitEdit or cancelEdit
Rename a keystartRename โ†’ submitRename โ†’ commitRename or cancelRenamecommitRename also carries oldKey + newKey
Add a propertystartAdd โ†’ submitAdd โ†’ commitAdd or cancelAdd
Delete or move a nodedelete or moveInstant โ€” fires once, no session
Save settledupdateSuccess or updateErrorOnly fired when an onUpdate ran; carries the operation (and, on error, the error)

A few things worth knowing:

  • A session ends with exactly one of commit* (applied) or cancel* (closed without applying). cancel* also fires when onUpdate returns null, and when a synchronous onUpdate rejects (false / { error } / throw) โ€” a sync verdict is known before the optimistic apply, so the session closes via cancel* (+ updateError) rather than commit*. An asynchronous reject still emits commit* first (the optimistic apply), then updateError.
  • A hold() gate, if you've set one, runs in the submit* window.
  • Add events describe the parent collection (the node you're adding into); commitAdd is where the add lands.
  • Array adds are instant โ€” they emit only commitAdd (no startAdd/submitAdd/cancelAdd, since there's no key-entry step).
  • A no-op confirm (the user submits without changing the value) still emits commitEdit โ€” the session closed cleanly, it just didn't change anything (and no update* follows, since onUpdate isn't run).
  • A type change mid-edit that's structural (to an object/array/custom node) is itself a commit, so it emits commitEdit while editing continues โ€” one session can emit multiple commitEdits.

โ–ถ Live example: Event signals

Listening to expansion events โ€” onCollapse

The onCollapse callback is executed when the user opens or collapses a node (or you drive it via editorRef.collapse). It receives the node's node data with the collapse flags spread on top:

type OnCollapseFunction = (
  nodeData: NodeData & {
    collapsed: boolean // closing = true, opening = false
    includeChildren: boolean // if opened/closed with the Modifier key to
                             // affect all descendants as well
  }
) => void

โ–ถ Live example: Collapse signals

Driving the editor โ€” the editorRef handle

You can drive the editor's UI imperatively via a handle: open a value-edit input session at a node, commit or cancel it, and collapse nodes. Create a ref with useRef and pass it to the editorRef prop (an ordinary prop, not the ref attribute):

import { useRef } from 'react'
import { JsonEditor, type JsonEditorHandle } from 'json-edit-react'

const editorRef = useRef<JsonEditorHandle>(null)

// ...
<JsonEditor data={data} setData={setData} editorRef={editorRef} />

// Then, from an event handler:
editorRef.current?.collapse({ path: ['user'], collapsed: true, includeChildren: true })
editorRef.current?.startEdit({ path: ['user', 'name'] })  // open the value editor
editorRef.current?.confirm()  // commit the open session (runs onUpdate)
editorRef.current?.cancel()   // discard the open session

The handle shape is:

interface JsonEditorHandle {
  // Collapse/expand a node (or a whole subtree, with `includeChildren`).
  // Same `CollapseState` shape as the `onCollapse` callback input.
  collapse: (state: CollapseState | CollapseState[]) => void
  // Open a value-edit session at a node; returns whether it opened (see below).
  startEdit: (options: StartEditOptions) => StartEditResult
  // Commit the open session (clicks the live confirm control), then exit.
  confirm: () => void
  // Discard the open session without committing.
  cancel: () => void
}

interface StartEditOptions {
  // The target node to edit.
  path: CollectionKey[]
  // Bypass `allowEdit` (default false). Skips ONLY the filter โ€” your
  // `onUpdate` still runs (and may reject) at `confirm()`.
  overrideRestrictions?: boolean
}

// `true` if the session opened, else why it didn't.
type StartEditResult = true | 'RESTRICTED' | 'PATH_NOT_FOUND'

interface CollapseState {
  path: CollectionKey[]
  collapsed: boolean
  includeChildren: boolean
}

Tip

Pass overrideRestrictions: true to bypass the filter. A common pattern is to lock the whole tree with allowEdit={false} and imperatively enable editing on one node through your own UI. It skips only the filter: your onUpdate still runs at confirm() and may reject or transform the value.

โ–ถ Live example: Imperative control

A few additional behaviours worth noting:

  • startEdit is synchronous and returns true if it opened the session, or the reason it didn't: 'PATH_NOT_FOUND' (the path doesn't exist in the current data) or 'RESTRICTED' (allowEdit blocks it) โ€” so you can give your own feedback (e.g. a toast) on a refused command. The target is never silently redirected to a different node.
  • confirm() commits the open session โ€” it triggers the same path as clicking the editor's confirm button, running your onUpdate. cancel() discards it. Only one session is open at a time, so both take no arguments.
  • startEdit will auto-reveal a target that's currently collapsed โ€” any collapsed ancestors expand so the node becomes visible and enters the session.

Note

JsonViewer exposes the same editorRef prop, but its handle (JsonViewerHandle) is collapse-only โ€” the editing actions aren't meaningful (and would bypass the read-only contract) in a viewer.

Back to Contents

Performance considerations

Important

For a large data set, the single most effective thing you can do is load it mostly collapsed โ€” the editor only renders nodes that are expanded into view, so a collapsed branch costs nothing until you open it.

Beyond that, JsonEditor re-renders at the granularity of a single node (editing one value re-renders just that node, not the whole tree), which you keep intact by passing referentially-stable props (below).

โ–ถ Live example: Massive data set

Keep non-callback props referentially stable

The editor decides whether a node can skip re-rendering by comparing its props by reference. So every object / array / function prop you pass should keep a stable identity across renders where it hasn't meaningfully changed โ€” define it at module scope, or wrap it in useMemo / useCallback. A brand-new value every render โ€” customNodeDefinitions={[โ€ฆ]}, allowEdit={(node) => โ€ฆ}, theme={{ โ€ฆ }} โ€” silently defeats this: it still works correctly, it just re-renders far more than it needs to.

In practice this covers every non-primitive prop except the event callbacks, in particular:

  • customNodeDefinitions โ€” and, since they live inside it, the condition functions and componentProps of each definition.
  • The restriction / filter props, whenever you give them a function (or an array/object) rather than a plain boolean โ€” allowEdit, allowDelete, allowAdd, allowDrag, allowTypeSelection, searchFilter, customText, and collapse when it's a filter function rather than a number.
  • theme (which carries your icons), translations, keyboardControls, customButtons, collapseClickZones โ€” and any other object/array prop.

The event callbacks are the exception โ€” pass them inline freely. onUpdate, onChange, onError, onEditEvent, onCollapse and onCopy are wrapped in a stable, always-latest identity internally, so an inline arrow there costs nothing. The stability rule is only about the non-callback props above.

theme is worth calling out specially: it feeds a React context, and a context update bypasses the per-node memo, so an unstable theme re-renders the entire tree on every render โ€” not just one node. If you build a theme inline (e.g. theme={['githubDark', { styles: โ€ฆ }]}), memoise it.

Back to Contents

Undo functionality

Even though Undo/Redo functionality is probably desirable in most cases, this is not built in to the component, for two main reasons:

  1. It would involve too much additional UI and this component is intentionally unopinionated about look and feel beyond the essentials (which are mostly customisable/style-able anyway)
  2. It is quite straightforward to implement using existing libraries. In fact, I have provided a simple hook in the @json-edit-react/utils package called useUndo, which is what I'm using in the Demo.
Back to Contents

Exported helpers & types

A few helper functions, components and types that might be useful in your own implementations (from creating Filter or Update functions, or Custom components) are exported from the package:

Functions & components

  • StringDisplay: main component used to display a string value. Useful as a building block in custom components โ€” handles truncation, "show more / show less" expansion, and the standard double-click-to-edit behaviour.
  • StringEdit: component used when editing a string value, can be useful for custom components
  • AutogrowTextArea: the auto-resizing textarea primitive used by StringEdit and the built-in string editor
  • useKeyboardListener: hook that attaches a keyboard listener to an element without native keyboard behaviour (used internally for the null value); exported for re-use in Custom components
  • IconSvg: renders an IconDefinition's parts (scale, viewBox, inner markup as children, plus its svgProps) as an <svg> โ€” the same renderer the editor uses for theme icons; handy for previewing a glyph outside the editor
  • matchNode, matchNodeKey: helpers for defining custom Search functions
  • extract: function to extract a deeply nested object value from a string path. Originally published at object-property-extractor
  • assign: function to set a deep object value from a string path. Originally published at object-property-assigner
  • isCollection: simple utility that returns true if input is a "Collection" (i.e. an Object or Array)
  • toPathString: transforms a path array to a string representation suitable for HTML name/id attributes, e.g. ["data", 0, "property1", "name"] => "data/0/property1/name". Keys are URL-encoded so the result is unambiguous even when keys contain / or other special characters.
  • splitPropertyString: the rough inverse for dot/bracket notation โ€” parses a property-path string into a path array, e.g. "data.organisations.nodes[0]" => ["data", "organisations", "nodes", 0]. Bracket indices become numbers (array indices); this is the same parsing extract/assign use, and is handy for building the path passed to the editorRef handle.
  • defaultTheme: the "default" theme baseline used when no theme prop is supplied. (Additional themes ship in @json-edit-react/themes.)
  • standardDataTypes: array containing all standard data types: [ 'string','number', 'boolean', 'null', 'object', 'array' ]
  • valueDataTypes: the scalar subset of the above โ€” [ 'string', 'number', 'boolean', 'null' ]
  • collectionDataTypes: the container subset โ€” [ 'object', 'array' ]

Types

Back to Contents

Issues & support

Please open an issue: https://github.com/CarlosNZ/json-edit-react/issues

Back to Contents

Inspiration

This component is heavily inspired by react-json-view, a great package that I've used in my own projects. However, it seems to have been abandoned now, and requires a few critical fixes, so I decided to create my own from scratch and extend the functionality while I was at it.

Back to Contents

Changelog

Back to Contents