Building the drag-and-drop page editor: dnd-kit + Zustand
arrayMove plus an order reindex, a 50-step undo stack, and localStorage draft recovery — the editor a non-developer drives without filing a ticket.
A block-based content platform is only as good as the screen where someone arranges the blocks. The data model can be elegant — a page as a sorted JSON array of typed blocks — but if the editor lets a translator fork the layout, drops order into a tangle of half-numbers, or quietly loses an hour of work on a browser crash, the elegance never reaches the person who has to ship a landing page on a Friday afternoon. This is a lesson-learned walkthrough of the drag-and-drop editor that sits on top of that model: a Zustand store, dnd-kit for the dragging, and four small decisions that each looked optional until the day they were not.
Every snippet below is copied from the running code, trimmed to the load-bearing lines. The four lessons, stated up front: reindex order to the array position on every change instead of reaching for fractional ranks; bound the undo stack so a long session cannot eat memory; autosave to localStorage and recover on load so a crash is survivable; and gate every structural mutation behind a base-language check so translations carry text, not structure.
The situation: an editor a non-developer has to trust
The editor's job is to turn a JSON array into a screen you can drag, and to turn the dragging back into a clean JSON array. It runs as a client component wrapped in a dnd-kit DndContext, with a Zustand store holding the single source of truth for sections. Here is the wiring that connects the two:
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
<SortableContext
items={sections.map((b) => b.id)}
strategy={verticalListSortingStrategy}
>
<Canvas />
</SortableContext>
</DndContext>
)
The versions this is built on, read straight from package.json: @dnd-kit/core@^6.3.1, @dnd-kit/sortable@^10.0.0, @dnd-kit/utilities@^3.2.2, and zustand@5.0.9. The PointerSensor with an 8px activation distance is a small but real choice — without it, every click registers as a drag and selecting a block becomes a fight. The KeyboardSensor is what makes the canvas reorderable without a mouse. The SortableContext takes the list of block ids and a vertical strategy; dnd-kit handles the visual lift and drop, and the store handles what the drop means. That split — dnd-kit owns the gesture, Zustand owns the truth — is the spine of everything that follows.
What I thought going in: order would be the hard part
Going in, the assumption was that keeping block positions consistent would need something clever. Drag-and-drop lists are where people reach for fractional indexing: give block A an order of 1.0 and block B 2.0, and when you drop something between them, assign 1.5 — no neighbours need touching. It is a well-known trick for collaborative or very long lists because a single move writes a single value.
The editor does the opposite, and it is almost boring how little code it takes. The drag handler reindexes the entire array to its new positions:
handleDragEnd: (event) => {
if (get().activeLang !== 'en') return
const { active, over } = event
if (!over || active.id === over.id) return
set((state) => {
const oldIndex = state.sections.findIndex((b) => b.id === active.id)
const newIndex = state.sections.findIndex((b) => b.id === over.id)
return {
undoStack: [...state.undoStack.slice(-49), { sections: state.sections, selectedId: state.selectedId }],
redoStack: [],
isDirty: true,
sections: arrayMove(state.sections, oldIndex, newIndex).map((b, i) => ({
...b,
order: i,
})),
}
})
},
arrayMove from @dnd-kit/sortable produces the reordered array, and then .map((b, i) => ({ ...b, order: i })) overwrites every block's order with its index. After any drop, order is guaranteed to be a clean 0..n sequence with no gaps, no decimals, no drift. There is no rank to reconcile and no merge logic, because the array itself is the order — order is just a denormalized copy of the index, written so the render path can sort without depending on storage order.
What actually happened: the boring approach won, and it won everywhere
The reindex-on-every-change pattern turned out not to be a drag-handler detail. It became the contract every structural mutation honours. Add a block at an index, and the same reindex runs:
addBlock: (type, atIndex) => {
if (get().activeLang !== 'en') return
const codeBlock = getCodeBlock(type)
const dbBlock = get().blockDefs.find((d) => d.type === type)
const defaultProps = codeBlock?.defaultProps ?? dbBlock?.defaultProps ?? {}
const newSection: BlockData = { id: uuidv4(), type, order: 0, props: { ...defaultProps } }
set((state) => {
let newSections: BlockData[]
if (atIndex !== undefined) {
newSections = [
...state.sections.slice(0, atIndex),
newSection,
...state.sections.slice(atIndex),
].map((b, i) => ({ ...b, order: i }))
} else {
newSections = [...state.sections, { ...newSection, order: state.sections.length }]
}
return {
undoStack: [...state.undoStack.slice(-49), { sections: state.sections, selectedId: state.selectedId }],
redoStack: [],
isDirty: true,
sections: newSections,
selectedId: newSection.id,
}
})
},
Delete does it too — state.sections.filter((b) => b.id !== id).map((b, i) => ({ ...b, order: i })) — and so do duplicateBlock, moveBlock, reorderBlocks, and pasteBlock. Every one ends with the same .map((b, i) => ({ ...b, order: i })). The new block gets a uuidv4() id so the instance is distinct from the block type and from any duplicate of it. Because the reindex is applied uniformly, there is no path through the editor that can produce a duplicate or skipped order. The save path even re-applies it one more time on the way to the API (sections: enSectionsToSave.map((s, i) => ({ ...s, order: i }))), so the persisted document is clean regardless of how it got there.
This is the payoff fractional ranks were supposed to provide — never having to think about ordering bugs — except it arrived for free because the list is short. A marketing page is a handful of sections, not ten thousand rows. Rewriting an array of a dozen objects on each edit is imperceptible.
The lesson, stated as a rule I would apply tomorrow
Three rules came out of building this, each a line of code that earns its keep.
First, reindex to the array position unless the list is large or concurrently edited. Fractional ranks solve a problem this editor does not have. The full re-index is trivial to reason about — there is exactly one invariant, order === index, and every mutation restores it.
Second, bound the undo stack. Every mutation snapshots the previous state, and the snapshot is capped:
undo: () => {
set((state) => {
if (state.undoStack.length === 0) return {}
const prev = state.undoStack[state.undoStack.length - 1]
return {
sections: prev.sections,
selectedId: prev.selectedId,
undoStack: state.undoStack.slice(0, -1),
redoStack: [...state.redoStack.slice(-49), { sections: state.sections, selectedId: state.selectedId }],
}
})
},
The pattern [...state.undoStack.slice(-49), prev] keeps the last 49 entries plus the one being pushed, capping the stack at 50 snapshots. Every structural mutation pushes onto undoStack and clears redoStack, because once you branch the history a forward path no longer makes sense. Undo and redo move full state snapshots between two equally-bounded stacks. The cap is the whole point: a snapshot holds the entire sections array, and an editing session can run for an hour. Without the slice(-49), a careful editor making hundreds of small changes would slowly accumulate hundreds of full-page copies in memory.
Third, autosave and offer recovery — do not auto-merge. A timer writes the editor state to localStorage ten seconds after the last change, but only while the page is dirty:
autoSaveTimer.current = setTimeout(() => {
const s = useEditorStore.getState()
try {
localStorage.setItem(DRAFT_KEY(pageId), JSON.stringify({
_savedAt: Date.now(),
title: s.title,
slug: s.slug,
status: s.status,
sections: s.sections,
enSections: s.enSections,
translationCache: s.translationCache,
savedLangs: s.savedLangs,
}))
} catch { /* localStorage might be full */ }
}, 10_000)
The full editor state goes to a per-page localStorage key. On the next load the editor checks for that key and, if it finds one, shows a banner with Restore and Dismiss instead of silently overwriting — the user decides whether the local draft or the server version wins. The try/catch matters: localStorage can be full, and an autosave that throws must not break the editor.
The key is namespaced per page, and the store side of recovery is symmetric — one action restores, one dismisses:
const draftKey = (id: string) => `dynamic_editor_draft_${id || 'new'}`
dismissDraft: () => {
const { pageId } = get()
try { if (typeof window !== 'undefined') localStorage.removeItem(draftKey(pageId)) } catch {}
set({ pendingDraft: null })
},
draftKey falls back to 'new' for an unsaved page, so the create flow and the edit flow do not collide on one key. dismissDraft removes the entry and clears the in-memory pendingDraft flag that drives the banner, again wrapping the localStorage call in a try/catch because a storage failure must never be the thing that breaks dismissing a draft. The restore counterpart reads the same key, reindexes the saved sections on the way back in, and marks the page dirty — the recovered draft is treated as unsaved work, not as a clean load.
Where the lesson does not apply
The order rule has a clear boundary. The full re-index writes the whole array on every change, which is fine for a dozen sections and wrong for a list of thousands, or for any list edited by multiple people at once. Two editors reordering the same large list would have their full-array writes stomp each other; that is exactly the case where fractional or gap-based ranks earn their complexity, because a single move touches a single value and merges cleanly. The honest framing is that this editor chose the simple approach because the list is bounded and single-user, not because fractional ranks are wrong in general.
The localStorage recovery has its own edge. Drafts go stale. A draft saved against an older version of the page can drift from what is now on the server, which is precisely why recovery is a banner the user accepts rather than an automatic merge — the code refuses to guess. And localStorage is per-browser, so a draft saved on a laptop does nothing for the same user on a phone. For real cross-device safety you would need server-side autosave, which is a different feature with a different cost.
CTA: test it against your last list editor
If you have built a reorderable list recently, pull up the code and check one thing: what guarantees your position field stays consistent after a delete from the middle? If the answer is a comment that says "should be fine," try the array-index reindex instead and watch a category of bugs disappear.
Trade-off: full re-index versus surgical writes
The recommendation accepts a real cost. Reindexing the entire array on every edit means every structural change is an O(n) rewrite and, at save time, the whole sections array is sent to the API rather than a diff. For a marketing page that is nothing; for a list that grows without bound it would become the bottleneck. There is no fake certainty here — the trade is deliberately tuned to a short, single-user list. The day the list stops being short, the rule flips, and the editor would need rank-based ordering and a merge strategy. Until then, paying O(n) to never debug an ordering glitch is a bargain.
Business impact: edits that stick, and one structure to translate
For the people who pay for this, two consequences matter. First, the autosave-and-recover loop means a browser crash or a closed tab during a long edit is survivable — the work that would otherwise vanish is offered back on the next load. For a team building pages under deadline, "I lost an hour of layout work" stops being a sentence anyone says. Second, the base-language guard — the if (get().activeLang !== 'en') return at the top of every structural mutation — means reordering, adding, deleting, and pasting only run while editing the base language. Translators change text, not structure. That keeps one canonical layout that every language inherits, instead of several diverging page structures that have to be maintained in parallel. The business reads that as cheaper translation and a site that does not quietly rot into per-language inconsistency.
What to do next
Three lines are worth copying into your own editor, whatever stack it is on: the .map((b, i) => ({ ...b, order: i })) reindex after every list mutation, the [...stack.slice(-49), snapshot] bounded-history push, and the autosave-to-localStorage-then-offer-restore loop. None of them is clever. All three are the kind of thing you notice the absence of only after you have lost work, shipped an ordering bug, or watched memory climb in a long session.
If you are weighing how much structure to put behind a content editor your client's own team will drive — where to validate, where to guard, how much to let non-developers touch — that is a trade-off worth talking through before the first version ships, not after the first support ticket.
Related Articles
Same CategoryComments (0)
Newsletter
Stay updated! Get all the latest and greatest posts delivered straight to your inbox