Blog

Part 54 · Build Drag and Drop in React

Build a small task board step by step, as in a coding interview. Move cards with the mouse, a finger and the keyboard, and show where each card will land.

This part builds one more small app, as an interview task. It is a task board: columns of cards, like “To do”, “Doing” and “Done”. You move a card by dragging it. People often call this a kanban board.

We’ll build it in steps, and each step runs in the page. On the way we use arrays in state (Part 5), keys (Part 8) and refs (Part 14). We also use pointer events from Part 40‘s mouse tracker, and memo (Part 20). Then live regions (Part 46) and the keyboard (Part 47).

At the end, we come back to the file explorer from Part 51. It promised a way to drag a folder into another folder. That has one trap, and we’ll close it.

Try this first

Read this code. Don’t press Run yet.

import { useState } from 'react'

export default function App() {
  const [side, setSide] = useState('left')

  return (
    <div style={{ display: 'flex', gap: 8 }}>
      {['left', 'right'].map(name => (
        <div
          key={name}
          onDragEnter={() => console.log('dragenter', name)}
          onDrop={() => {
            console.log('drop', name)
            setSide(name)
          }}
          style={{ flex: 1, minHeight: 100, padding: 8, border: '2px dashed gray' }}
        >
          {name}
          {side === name && (
            <p
              draggable
              onDragStart={e => e.dataTransfer.setData('text/plain', 'card')}
              style={{ padding: 8, background: 'gold', color: 'black', cursor: 'grab' }}
            >
              Drag me
            </p>
          )}
        </div>
      ))}
    </div>
  )
}

The yellow card has draggable, so the browser lets you drag it. The right box has an onDrop handler that moves the card there. Make a guess. If you drag the card into the right box and let go, will it move?

Now press Run, and try it with a mouse.

It doesn’t move. Look at the Console. It shows dragenter right when the card enters the right box. But it never shows drop right. The drop event never happened.

Press Edit. Add this line to the box <div>, under key={name}:

onDragOver={e => e.preventDefault()}

Run it again. Now the card moves, and the Console shows drop right.

We tried both versions in three browsers, with no window, driven by a program: Chromium (the base of Chrome), Firefox and WebKit (the base of Safari). Without the new line, drop fired 0 times in all three. With it, the card moved in all three. Each line showed once in the Console. Strict Mode runs your components twice in development, but not your event handlers.

Why one line makes the difference is the first thing to learn about dragging. We’ll get there in Step 2.

The task

Here is the task, the way an interviewer might give it:

  1. Show three columns: “To do”, “Doing” and “Done”. Each holds a list of cards.
  2. Drag a card to another place in the same column, or into another column.
  3. While you drag, show where the card will land.
  4. It must work on a phone, with a finger.
  5. It must work without dragging at all: with a keyboard, and with simple clicks.

Before you code, ask questions. Is the order inside a column important? (Yes.) Is the board saved anywhere? (Not here.) Can a column be empty? (Yes, and you must be able to drop into an empty column.)

Then plan the steps. The part that changes the data is the same for every way of moving a card. So we write it first, and test it with no mouse at all.

Step 1: the data and one pure function

The board keeps each column as an array of card ids, in order. The words on each card don’t change, so they live outside state.

type ColumnId = 'todo' | 'doing' | 'done'
type Board = Record<ColumnId, string[]>
type Place = { columnId: ColumnId; index: number }

const columnIds: ColumnId[] = ['todo', 'doing', 'done']
const cardText: Record<string, string> = {
  a: 'Write the plan',
  b: 'Draw the screens',
  c: 'Build the board',
  d: 'Test on a phone',
}
const startBoard: Board = { todo: ['a', 'b', 'c'], doing: ['d'], done: [] }

Record<ColumnId, string[]> is TypeScript for “an object with the keys todo, doing and done, each holding an array of text”. A Place says where a card is: a column, and an index in that column. An index is a place in an array, counting from 0.

Every move is the same change, whatever the input. Take the card out of one place, and put it in another. So one function does it:

type ColumnId = 'todo' | 'doing' | 'done'
type Board = Record<ColumnId, string[]>
type Place = { columnId: ColumnId; index: number }

function moveCard(board: Board, from: Place, to: Place): Board {
  if (from.columnId === to.columnId && from.index === to.index) return board
  const id = board[from.columnId][from.index]
  const next: Board = { ...board, [from.columnId]: board[from.columnId].toSpliced(from.index, 1) }
  next[to.columnId] = next[to.columnId].toSpliced(to.index, 0, id)
  return next
}

toSpliced is the copying form of splice, from Part 5. toSpliced(from.index, 1) gives a new array without the card. toSpliced(to.index, 0, id) gives a new array with the card put in at to.index.

moveCard is pure: it returns a new board and changes nothing that already exists. next[to.columnId] = ... changes only next, a new object that this function just made.

The one rule that makes moves easy

There is a choice hidden in to.index. Say “To do” holds a, b, c, and you drag a down, below b. Is the new index 1 or 2?

It depends on how you count. While you drag, a is in your hand, not in the list. The list you drop into is b, c. Below b is index 1 in that list. So we use one rule everywhere: to.index counts places in the list without the card you are moving. It is the card’s index after the move.

Without this rule, moving down in the same column is off by one. The card lands one place too far. It is a classic interview bug.

Test it with plain calls

Here are checks that call moveCard as a plain function, as Part 15 did for a reducer. No mouse, no browser.

type ColumnId = 'todo' | 'doing' | 'done'
type Board = Record<ColumnId, string[]>
type Place = { columnId: ColumnId; index: number }

function moveCard(board: Board, from: Place, to: Place): Board {
  if (from.columnId === to.columnId && from.index === to.index) return board
  const id = board[from.columnId][from.index]
  const next: Board = { ...board, [from.columnId]: board[from.columnId].toSpliced(from.index, 1) }
  next[to.columnId] = next[to.columnId].toSpliced(to.index, 0, id)
  return next
}

const start: Board = { todo: ['a', 'b', 'c'], doing: ['d'], done: [] }
const results: string[] = []

function check(name: string, ok: boolean) {
  results.push((ok ? 'pass: ' : 'FAIL: ') + name)
}

const down = moveCard(start, { columnId: 'todo', index: 0 }, { columnId: 'todo', index: 1 })
check('down one place in a column', down.todo.join() === 'b,a,c')
const bottom = moveCard(start, { columnId: 'todo', index: 0 }, { columnId: 'todo', index: 2 })
check('down to the bottom', bottom.todo.join() === 'b,c,a')
const up = moveCard(start, { columnId: 'todo', index: 2 }, { columnId: 'todo', index: 0 })
check('up to the top', up.todo.join() === 'c,a,b')
const across = moveCard(start, { columnId: 'todo', index: 1 }, { columnId: 'doing', index: 0 })
check('into another column', across.todo.join() === 'a,c' && across.doing.join() === 'b,d')
const empty = moveCard(start, { columnId: 'doing', index: 0 }, { columnId: 'done', index: 0 })
check('into an empty column', empty.doing.length === 0 && empty.done.join() === 'd')
check('the same place gives the same board', moveCard(start, { columnId: 'todo', index: 1 }, { columnId: 'todo', index: 1 }) === start)
check('the old board did not change', start.todo.join() === 'a,b,c' && start.done.length === 0)
check('a column that did not change is the same array', across.done === start.done)

export default function App() {
  return (
    <ul>
      {results.map(r => (
        <li key={r}>{r}</li>
      ))}
    </ul>
  )
}

Run it. All eight checks say “pass”.

Now break it on purpose. Press Edit, and change next[to.columnId].toSpliced( to board[to.columnId].toSpliced(. That puts the card into the old column, which still has the card in it. Run it. Three checks fail: “down one place in a column”, “down to the bottom” and “up to the top”. The card shows up twice in its own column. Moves between columns still pass, so a test that only tried those would miss it.

“The same place gives the same board” matters too. A drop on the card’s own place then leaves the board state as it was.

Step 2: HTML drag and drop

Browsers have their own way to drag things, called the HTML Drag and Drop API. An API is a set of functions and events that a program can use. It takes three pieces.

The thing you drag. MDN is a set of web guides that many developers use. It says to set an element’s draggable attribute to "true". Then the user can drag it. In JSX, <li draggable> gives draggable="true" on the page.

The data. When the drag starts, the dragstart event fires on the card. Its e.dataTransfer holds the data the drag carries. We put the card’s id in it with setData('text/plain', id). text/plain is the name of the data type: plain text.

The place you drop on. This is the trap from “Try this first”. A drop target is a place that accepts a drop. MDN says: “By default, most elements are not drop targets.” An element becomes one when code cancels its dragover event with preventDefault(). dragover fires again and again while a drag is over an element. If no code cancels it, the browser decides you can’t drop there, and drop never fires.

Here is the board with all three pieces:

import { useState, type DragEvent } from 'react'

type ColumnId = 'todo' | 'doing' | 'done'
type Board = Record<ColumnId, string[]>
type Place = { columnId: ColumnId; index: number }

const columnIds: ColumnId[] = ['todo', 'doing', 'done']
const columnNames: Record<ColumnId, string> = { todo: 'To do', doing: 'Doing', done: 'Done' }
const cardText: Record<string, string> = {
  a: 'Write the plan',
  b: 'Draw the screens',
  c: 'Build the board',
  d: 'Test on a phone',
}
const startBoard: Board = { todo: ['a', 'b', 'c'], doing: ['d'], done: [] }

function moveCard(board: Board, from: Place, to: Place): Board {
  if (from.columnId === to.columnId && from.index === to.index) return board
  const id = board[from.columnId][from.index]
  const next: Board = { ...board, [from.columnId]: board[from.columnId].toSpliced(from.index, 1) }
  next[to.columnId] = next[to.columnId].toSpliced(to.index, 0, id)
  return next
}

function placeOf(board: Board, id: string): Place | null {
  for (const columnId of columnIds) {
    const index = board[columnId].indexOf(id)
    if (index !== -1) return { columnId, index }
  }
  return null
}

export default function App() {
  const [board, setBoard] = useState(startBoard)

  function handleDrop(e: DragEvent<HTMLElement>, columnId: ColumnId) {
    e.preventDefault()
    const id = e.dataTransfer.getData('text/plain')
    const from = placeOf(board, id)
    if (from === null) return
    const rest = board[columnId].filter(x => x !== id)
    setBoard(moveCard(board, from, { columnId, index: rest.length }))
  }

  return (
    <div style={{ display: 'flex', gap: 8 }}>
      {columnIds.map(columnId => (
        <section
          key={columnId}
          onDragOver={e => e.preventDefault()}
          onDrop={e => handleDrop(e, columnId)}
          style={{ flex: 1, minWidth: 0, padding: 8, borderRadius: 8, background: '#e8ecf2', color: '#111' }}
        >
          <h3 style={{ margin: '0 0 8px', fontSize: 16 }}>{columnNames[columnId]}</h3>
          <ul style={{ listStyle: 'none', margin: 0, padding: 0, minHeight: 40 }}>
            {board[columnId].map(id => (
              <li
                key={id}
                draggable
                onDragStart={e => e.dataTransfer.setData('text/plain', id)}
                style={{ padding: 8, marginBottom: 8, background: 'white', border: '1px solid #bbb', borderRadius: 6, cursor: 'grab' }}
              >
                {cardText[id]}
              </li>
            ))}
          </ul>
        </section>
      ))}
    </div>
  )
}

Run it, and drag “Write the plan” into “Done”. It goes to the bottom of that column. For now, every drop puts the card at the end. Step 4 fixes that.

Two parts need a word more:

  • handleDrop reads the id back with getData, finds where the card is now, and calls moveCard. The new index is the length of the column without the card, so the card goes last.
  • e.preventDefault() in handleDrop stops the browser’s own action for a drop. MDN’s examples call it there too. For some data, like a link, that action can be to open it.

placeOf can return null. A drop can carry text from anywhere: MDN lists “dragging data into a page” as one of the uses of this API. A word dragged in from another page is not one of our ids, so we ignore it.

What we measured

We ran this board in headless Chromium, Firefox and WebKit. Headless means the browser runs with no window, under a program’s control. The program dragged with a real mouse, through the same playground you are using.

  • “Write the plan” dropped on “Done” went to the end of “Done”.
  • “Test on a phone” dropped on “To do” went to the end of “To do”.
  • getData in the drop handler gave back the id, a.

Chromium and Firefox did this every time. In WebKit, our test tool’s drags sometimes ended with no drop at all. The drags that did drop gave the same results.

MDN has one more rule. Apart from dragstart, you can read the data only “during the drop event”. We checked getData('text/plain') inside dragover. Chromium and WebKit gave an empty string. Firefox gave the id. So don’t count on it during the drag. Step 6 needs to know this.

Where HTML drag and drop doesn’t work

The site caniuse.com tracks which browsers support each web feature. For drag and drop, it lists Safari on iPhone and iPad as supported since version 15. It lists Chrome on Android as supported. But it lists Firefox for Android and Samsung Internet as not supported at all.

We also tried a finger drag in headless Chromium, set up as a touch screen. The finger went all the way to “Done”. No dragstart fired, and the card didn’t move. That is a computer’s browser pretending, not a real phone. But it shows the risk. A board built only on HTML drag and drop may not work for some of your users on phones.

That is requirement 4. For a finger, we need another tool.

Step 3: pointer events

A pointer is a mouse, a pen or a finger on a touch screen (Part 40). Pointer events work for all three. To drag, we need three of them:

  • pointerdown: the button goes down, or the finger touches the screen.
  • pointermove: the pointer moves.
  • pointerup: the button goes up, or the finger leaves the screen.

Here is one card you can drag around a box:

import { useRef, useState, type PointerEvent } from 'react'

export default function App() {
  const [pos, setPos] = useState({ x: 0, y: 0 })
  const [dragging, setDragging] = useState(false)
  const grab = useRef({ x: 0, y: 0 })

  function handlePointerDown(e: PointerEvent<HTMLDivElement>) {
    e.currentTarget.setPointerCapture(e.pointerId)
    grab.current = { x: e.clientX - pos.x, y: e.clientY - pos.y }
    setDragging(true)
  }

  function handlePointerMove(e: PointerEvent<HTMLDivElement>) {
    if (!dragging) return
    setPos({ x: e.clientX - grab.current.x, y: e.clientY - grab.current.y })
  }

  return (
    <div style={{ position: 'relative', height: 160, outline: '2px dashed gray', margin: 8 }}>
      <div
        onPointerDown={handlePointerDown}
        onPointerMove={handlePointerMove}
        onPointerUp={() => setDragging(false)}
        onPointerCancel={() => setDragging(false)}
        style={{
          position: 'absolute',
          left: pos.x,
          top: pos.y,
          padding: 8,
          background: 'gold',
          color: 'black',
          cursor: dragging ? 'grabbing' : 'grab',
          touchAction: 'none',
          userSelect: 'none',
        }}
      >
        Drag me
      </div>
      <p style={{ position: 'absolute', bottom: 0, margin: 4 }}>
        {dragging ? 'Moving' : 'Resting'} at {Math.round(pos.x)}, {Math.round(pos.y)}
      </p>
    </div>
  )
}

Run it, and drag the yellow card with the mouse. On a phone, drag it with your finger.

Three lines do the important work.

setPointerCapture(e.pointerId). MDN says it makes the element “the capture target of future pointer events”. Every later event from this pointer goes to the card, even when the pointer is no longer over it. We measured why that matters. We pressed the mouse on the card and jumped it 200 pixels to the right in one move. With capture, the card followed in Chromium, Firefox and WebKit. Without it, the pointermove went to whatever was under the mouse. The card stayed where it was, in all three. The pointerup went somewhere else too, so the drag never ended. The card still said “Moving” after we let go.

touchAction: 'none'. Part 40 met this one. It tells the browser not to scroll or zoom when a finger moves on this card. Without it, the browser may decide the finger wants to scroll the page. MDN says that’s one reason the browser fires pointercancel and stops sending events. We tried it in headless Chromium with a finger. With touchAction: 'none', the card followed the finger all the way. Without it, Chromium fired pointercancel early, and the card stopped. It had moved only 20 pixels of the 100.

grab is a ref (Part 14). It holds the spot where you took hold of the card. Without it, the card would jump to put its corner under the pointer. The page never shows it, so it doesn’t need to be state.

userSelect: 'none' stops the mouse from selecting the card’s words while it drags. onPointerCancel ends the drag if the browser takes the pointer away.

With pointer events, nothing is dropped for you. There is no drop event and no drop target. When pointerup fires, your code must work out what is under the pointer, and where in that list. That is Step 4.

Step 4: the board, and where the card will land

Now the full board with pointer events. While you drag, a blue line shows where the card will land.

Working out the place

We need two numbers from the pointer: which column, and which index in it.

The column is the one whose left and right edges hold the pointer’s x.

The index comes from the middle of each card. Count the other cards in that column whose middle is above the pointer. If 2 cards are above it, the dragged card lands at index 2. The dragged card itself is left out, because of the rule from Step 1.

Both are plain math on numbers, so they go in a pure function:

type ColumnId = 'todo' | 'doing' | 'done'
type Place = { columnId: ColumnId; index: number }
type ColumnBox = { columnId: ColumnId; left: number; right: number; middles: number[] }

function dropPlace(x: number, y: number, boxes: ColumnBox[]): Place | null {
  const box = boxes.find(b => x >= b.left && x < b.right)
  if (box === undefined) return null
  return { columnId: box.columnId, index: box.middles.filter(m => m < y).length }
}

middles is the y of the middle of each other card in the column, from top to bottom. We tried it with a column from 0 to 100 across, and cards with middles at 50, 100 and 150. A pointer at y 120 gave index 2. A pointer at 20 gave index 0, and one at 200 gave index 3. A pointer at x 130, outside the column, gave null.

Where do the numbers come from? getBoundingClientRect() gives an element’s edges on the screen. We read them once, on pointerdown. During the drag, only the dragged card moves on the screen, so the numbers stay right. Reading them once is cheaper than reading them on every move.

Drawing the line without moving anything

The line must not push the cards around. If it did, the cards would move under the pointer, and the index would change again.

So the line is a boxShadow on a card. It is a blue band just above the card, or just below the last card. A shadow is drawn outside the card and takes no room. We checked in all three browsers: when the line appeared, no other card moved, not even by 1 pixel. The dragged card moves with transform, which takes no room either. Its old space stays where it was.

The app

import { memo, useCallback, useRef, useState, type PointerEvent } from 'react'

type ColumnId = 'todo' | 'doing' | 'done'
type Board = Record<ColumnId, string[]>
type Place = { columnId: ColumnId; index: number }
type ColumnBox = { columnId: ColumnId; left: number; right: number; middles: number[] }
type Drag = { id: string; from: Place; dx: number; dy: number; over: Place | null }

const columnIds: ColumnId[] = ['todo', 'doing', 'done']
const columnNames: Record<ColumnId, string> = { todo: 'To do', doing: 'Doing', done: 'Done' }
const cardText: Record<string, string> = {
  a: 'Write the plan',
  b: 'Draw the screens',
  c: 'Build the board',
  d: 'Test on a phone',
}
const startBoard: Board = { todo: ['a', 'b', 'c'], doing: ['d'], done: [] }

function moveCard(board: Board, from: Place, to: Place): Board {
  if (from.columnId === to.columnId && from.index === to.index) return board
  const id = board[from.columnId][from.index]
  const next: Board = { ...board, [from.columnId]: board[from.columnId].toSpliced(from.index, 1) }
  next[to.columnId] = next[to.columnId].toSpliced(to.index, 0, id)
  return next
}

function placeOf(board: Board, id: string): Place | null {
  for (const columnId of columnIds) {
    const index = board[columnId].indexOf(id)
    if (index !== -1) return { columnId, index }
  }
  return null
}

function dropPlace(x: number, y: number, boxes: ColumnBox[]): Place | null {
  const box = boxes.find(b => x >= b.left && x < b.right)
  if (box === undefined) return null
  return { columnId: box.columnId, index: box.middles.filter(m => m < y).length }
}

type CardProps = {
  id: string
  dx: number
  dy: number
  lifted: boolean
  line: 'above' | 'below' | null
  onPointerDown: (e: PointerEvent<HTMLLIElement>, id: string) => void
}

function Card({ id, dx, dy, lifted, line, onPointerDown }: CardProps) {
  let shadow: string | undefined
  if (line === 'above') shadow = '0 -6px 0 royalblue'
  if (line === 'below') shadow = '0 6px 0 royalblue'
  return (
    <li
      data-card={id}
      onPointerDown={e => onPointerDown(e, id)}
      style={{
        position: 'relative',
        zIndex: lifted ? 1 : 0,
        padding: 8,
        marginBottom: 8,
        background: 'white',
        border: '1px solid #bbb',
        borderRadius: 6,
        boxShadow: shadow,
        transform: `translate(${dx}px, ${dy}px)`,
        opacity: lifted ? 0.8 : 1,
        cursor: lifted ? 'grabbing' : 'grab',
        touchAction: 'none',
        userSelect: 'none',
      }}
    >
      {cardText[id]}
    </li>
  )
}

const MemoCard = memo(Card)

export default function App() {
  const [board, setBoard] = useState(startBoard)
  const [drag, setDrag] = useState<Drag | null>(null)
  const columnsRef = useRef(new Map<ColumnId, HTMLElement>())
  const startRef = useRef({ x: 0, y: 0, boxes: [] as ColumnBox[] })

  const handlePointerDown = useCallback(
    (e: PointerEvent<HTMLLIElement>, id: string) => {
      if (e.button !== 0 || !e.isPrimary) return
      const from = placeOf(board, id)
      if (from === null) return
      e.currentTarget.setPointerCapture(e.pointerId)
      const boxes: ColumnBox[] = []
      for (const [columnId, el] of columnsRef.current) {
        const edges = el.getBoundingClientRect()
        const middles = [...el.querySelectorAll<HTMLElement>('[data-card]')]
          .filter(card => card.dataset.card !== id)
          .map(card => {
            const r = card.getBoundingClientRect()
            return r.top + r.height / 2
          })
        boxes.push({ columnId, left: edges.left, right: edges.right, middles })
      }
      startRef.current = { x: e.clientX, y: e.clientY, boxes }
      setDrag({ id, from, dx: 0, dy: 0, over: from })
    },
    [board],
  )

  function handlePointerMove(e: PointerEvent<HTMLDivElement>) {
    if (drag === null) return
    const start = startRef.current
    const over = dropPlace(e.clientX, e.clientY, start.boxes)
    setDrag({ ...drag, dx: e.clientX - start.x, dy: e.clientY - start.y, over })
  }

  function handlePointerUp() {
    if (drag === null) return
    if (drag.over !== null) setBoard(moveCard(board, drag.from, drag.over))
    setDrag(null)
  }

  // Where the line goes: null while the card is over its own place.
  let target: Place | null = null
  if (drag !== null && drag.over !== null) {
    const { over, from } = drag
    if (over.columnId !== from.columnId || over.index !== from.index) target = over
  }

  function lineFor(columnId: ColumnId, id: string): 'above' | 'below' | null {
    if (drag === null || target === null || target.columnId !== columnId || id === drag.id) return null
    const others = board[columnId].filter(x => x !== drag.id)
    const i = others.indexOf(id)
    if (i === target.index) return 'above'
    if (i === others.length - 1 && target.index === others.length) return 'below'
    return null
  }

  return (
    <div
      onPointerMove={handlePointerMove}
      onPointerUp={handlePointerUp}
      onPointerCancel={() => setDrag(null)}
      style={{ display: 'flex', gap: 8 }}
    >
      {columnIds.map(columnId => {
        const isEmptyTarget =
          target?.columnId === columnId && board[columnId].every(x => x === drag?.id)
        return (
          <section
            key={columnId}
            ref={el => {
              if (el !== null) columnsRef.current.set(columnId, el)
              return () => {
                columnsRef.current.delete(columnId)
              }
            }}
            style={{
              flex: 1,
              minWidth: 0,
              padding: 8,
              borderRadius: 8,
              background: '#e8ecf2',
              color: '#111',
              outline: isEmptyTarget ? '3px solid royalblue' : undefined,
            }}
          >
            <h3 style={{ margin: '0 0 8px', fontSize: 16 }}>{columnNames[columnId]}</h3>
            <ul style={{ listStyle: 'none', margin: 0, padding: 0, minHeight: 40 }}>
              {board[columnId].map(id => (
                <MemoCard
                  key={id}
                  id={id}
                  dx={drag?.id === id ? drag.dx : 0}
                  dy={drag?.id === id ? drag.dy : 0}
                  lifted={drag?.id === id}
                  line={lineFor(columnId, id)}
                  onPointerDown={handlePointerDown}
                />
              ))}
            </ul>
          </section>
        )
      })}
    </div>
  )
}

Run it. Drag “Write the plan” over “Doing”, and move it up and down past “Test on a phone”. The blue line jumps from above that card to below it. Let go, and the card lands where the line was. Let go between two columns, and the card goes back.

1. pointerdown on a card: capture the pointer, read every other card's middle. 2. The card follows the pointer. Over its own place, no line shows. 3. Over Doing, above the middle of the card: index 0. Line above it. 4. Below its middle: index 1, the end. The line moves below it. 5. pointerup: moveCard(board, from, over). React renders the new board. To do Doing Done Draw the screens Build the board Test on a phone Write the plan from: To do, index 0 board, in state todo: [a, b, c] doing: [d] done: []

Where the card will land comes from the pointer and the middles of the other cards. Press play, or step through it.

Here are the same steps in words:

  1. You press on “Write the plan”. handlePointerDown captures the pointer. It reads where each column is, and the middle of every other card. The card’s place, from, is “To do”, index 0.
  2. You move. handlePointerMove sets dx and dy, so the card follows. dropPlace gives “To do”, index 0: its own place. No line shows.
  3. Over “Doing”, above the middle of “Test on a phone”, dropPlace gives index 0. The line shows above that card.
  4. Below its middle, the index is 1. That is the end of the list, so the line moves below the last card.
  5. You let go. handlePointerUp calls moveCard(board, from, over). The board state changes, and React renders the card in its new place.

We ran exactly this drag in headless Chromium, Firefox and WebKit, with the mouse, in the playground. All three gave the same result: “To do” became Draw the screens, Build the board, and “Doing” became Test on a phone, Write the plan. Halfway through, the line was a shadow on “Test on a phone”. We also dragged “Build the board” to the top of its own column. It landed at index 0, above “Write the plan”, in all three.

Then we tried a finger in headless Chromium, set up as a touch screen. A finger drag of “Write the plan” into “Done” moved it there, as the mouse did.

A few smaller things in the code:

  • columnsRef holds each column’s element in a Map. A ref callback adds it, and its cleanup removes it. That is the pattern from Part 14.
  • e.button !== 0 || !e.isPrimary skips any press that is not the main one. MDN says button is 0 for the “Main button, usually the left button”. On a touch screen, MDN says a pointer is primary if it touched “when there were no other active touches”. So a right click or a second finger doesn’t start a drag.
  • target is where the line goes. It is null while the card is over its own place, so no line or frame shows there.
  • The move and up handlers sit on the outer <div>, not on each card. The card has the capture, so the events go to it. Then they bubble up to the <div> (Part 6).
  • An empty column gets a blue frame instead of a line. It has no card to draw the line on.

Is it fast enough?

During a drag, pointermove fires many times a second, and each one sets state. So App renders again on every move.

Card is wrapped in memo (Part 20). For a card you are not dragging, dx and dy stay 0, and lifted stays false. line changes only when the line moves onto or off that card. onPointerDown is the same function every time, because useCallback keeps it until board changes. And board doesn’t change until you let go.

We counted the calls to Card, with Strict Mode on as in the playground. We dragged “Write the plan” across to “Doing” in 10 moves:

Card calls in 10 moves (Chromium and Firefox)
With memo 22
Without memo 80

Without memo, every move rendered all 4 cards, twice each because of Strict Mode: 8 calls a move. With memo, each move rendered only the dragged card, twice: 20 calls. “Test on a phone” rendered twice more, when the line appeared on it. WebKit sent 9 moves, not 10, so it gave 20 and 72.

Then we took useCallback away, so handlePointerDown was a new function on every render. With memo still on, the count went back up to 80 (72 in WebKit). A new function in the props makes memo useless, as Part 20 showed.

For 4 cards, 80 calls is fine. So we added 196 more cards to “Done” and did the same drag. Without memo, Card ran 4,000 times in Chromium: all 200 cards, twice, on every move. With memo, it still ran 22 times. Firefox gave the same counts.

Step 5: moving cards without dragging

Dragging is hard or impossible for many people. Some use only a keyboard. Some use a screen reader. Some can point and click, but can’t hold a button down while they move.

The rules for this come from WCAG, the W3C’s guidelines for accessible web pages. Two of them apply here.

2.1.1 Keyboard. Everything must work from a keyboard. The W3C’s guide to the dragging rule says 2.1.1 and 2.1.3 “require dragging features to be keyboard accessible”. 2.1.1 has one exception: input that “depends on the path of the user’s movement and not just the endpoints”. Drawing with a pen is like that. Moving a card is not. Only where it starts and where it ends matter.

2.5.7 Dragging Movements. This is the dragging rule itself. It says that anything done by dragging “can be achieved by a single pointer without dragging, unless dragging is essential”. A single pointer way here means one click or one tap at a time. The guide adds that a keyboard way is not enough on its own. People on a touch screen “may not use a physical keyboard”.

The guide gives two examples that look like our board. In the first, you tap or click a card. Then a menu pops up with the columns to move it to. In the second, you pick a card “with a single tap or click”. Then you use “an arrow button to move the selected element”. We build the second one.

So we need both: keys, and buttons you can click.

The keys

For the keys, we follow dnd kit. It is a popular drag and drop library for React. Its guide lists these keys:

Key What it does
Space or Enter Picks up the focused card. Pressed again, drops it.
Arrow keys Move the picked-up card.
Escape Cancels: the card goes back to where it started.

Its guide says both Enter and Space must work, and calls this “the third rule of ARIA”. A <button> gives us both for free: Space and Enter both click it.

Telling a screen reader what happened

A screen reader user can’t see the card move. So each change goes into a live region (Part 46). It is always on the page, and only its text changes (Part 46).

dnd kit’s guide also says to describe places as positions, not indexes. A person says “position 1 of 3”, not “index 0”. So our messages say “To do, position 1 of 3”.

Keeping the focus on the card

There is one more trap. A card that moves into another column is a new element on the page. React removes it from one list and makes it in the other. The old element had the focus, and it is gone. So the focus is lost.

We measured it in headless Chromium, Firefox and WebKit, with the focus code taken out. After a move to another column, the focus was on the page’s <body> in all three.

Moves up and down inside one column kept the focus. That is not the browser’s doing. We moved a focused button with plain insertBefore, and Chromium and WebKit took the focus away. React helps here. We listened for the card’s own focus event during a move down. In all three browsers, the card got the focus again during the update. Chromium also fired a blur just before it. We read React’s own code (react-dom 19.3.0) to see why. React notes the focused element before it changes the page. Afterwards, it focuses that element again if it is still on the page. A card in a new column is a new element, so React can’t do that for it.

So after each key move, the code puts the focus back on the card. It must wait until React has put the card in its new place. flushSync from Part 48 does that. It makes React update the page before the next line runs.

The app

import { useRef, useState, type KeyboardEvent } from 'react'
import { flushSync } from 'react-dom'

type ColumnId = 'todo' | 'doing' | 'done'
type Board = Record<ColumnId, string[]>
type Place = { columnId: ColumnId; index: number }
type Direction = 'up' | 'down' | 'left' | 'right'

const columnIds: ColumnId[] = ['todo', 'doing', 'done']
const columnNames: Record<ColumnId, string> = { todo: 'To do', doing: 'Doing', done: 'Done' }
const cardText: Record<string, string> = {
  a: 'Write the plan',
  b: 'Draw the screens',
  c: 'Build the board',
  d: 'Test on a phone',
}
const startBoard: Board = { todo: ['a', 'b', 'c'], doing: ['d'], done: [] }
const keyToDirection: Record<string, Direction> = {
  ArrowUp: 'up',
  ArrowDown: 'down',
  ArrowLeft: 'left',
  ArrowRight: 'right',
}

function moveCard(board: Board, from: Place, to: Place): Board {
  if (from.columnId === to.columnId && from.index === to.index) return board
  const id = board[from.columnId][from.index]
  const next: Board = { ...board, [from.columnId]: board[from.columnId].toSpliced(from.index, 1) }
  next[to.columnId] = next[to.columnId].toSpliced(to.index, 0, id)
  return next
}

function placeOf(board: Board, id: string): Place | null {
  for (const columnId of columnIds) {
    const index = board[columnId].indexOf(id)
    if (index !== -1) return { columnId, index }
  }
  return null
}

function nextPlace(board: Board, from: Place, direction: Direction): Place | null {
  if (direction === 'up') return from.index > 0 ? { ...from, index: from.index - 1 } : null
  if (direction === 'down') {
    return from.index < board[from.columnId].length - 1 ? { ...from, index: from.index + 1 } : null
  }
  const column = columnIds.indexOf(from.columnId) + (direction === 'left' ? -1 : 1)
  if (column < 0 || column >= columnIds.length) return null
  const columnId = columnIds[column]
  return { columnId, index: Math.min(from.index, board[columnId].length) }
}

function describe(board: Board, id: string): string {
  const place = placeOf(board, id)
  if (place === null) return ''
  const size = board[place.columnId].length
  return `${columnNames[place.columnId]}, position ${place.index + 1} of ${size}`
}

export default function App() {
  const [board, setBoard] = useState(startBoard)
  const [picked, setPicked] = useState<{ id: string; before: Board } | null>(null)
  const [message, setMessage] = useState('')
  const cardsRef = useRef(new Map<string, HTMLButtonElement>())

  function focusCard(id: string) {
    cardsRef.current.get(id)?.focus()
  }

  function pickOrDrop(id: string) {
    if (picked === null) {
      setPicked({ id, before: board })
      setMessage(`Picked up ${cardText[id]}. ${describe(board, id)}.`)
    } else {
      setMessage(`Dropped ${cardText[picked.id]}. ${describe(board, picked.id)}.`)
      setPicked(null)
    }
  }

  function move(direction: Direction) {
    if (picked === null) return
    const from = placeOf(board, picked.id)
    if (from === null) return
    const to = nextPlace(board, from, direction)
    if (to === null) return
    const next = moveCard(board, from, to)
    flushSync(() => setBoard(next))
    setMessage(`${cardText[picked.id]}: ${describe(next, picked.id)}.`)
  }

  function cancel() {
    if (picked === null) return
    flushSync(() => setBoard(picked.before))
    setMessage(`Cancelled. ${cardText[picked.id]} is back in ${describe(picked.before, picked.id)}.`)
    setPicked(null)
  }

  function handleKeyDown(e: KeyboardEvent<HTMLButtonElement>, id: string) {
    if (picked?.id !== id) return
    if (e.key in keyToDirection) {
      move(keyToDirection[e.key])
    } else if (e.key === 'Escape') {
      cancel()
    } else {
      return
    }
    e.preventDefault()
    focusCard(id)
  }

  return (
    <div>
      <p id="board-help">
        Press Space or Enter on a card to pick it up. Move it with the arrow keys or the buttons.
        Press Space or Enter again to drop it, or Escape to cancel.
      </p>
      <div style={{ display: 'flex', gap: 8 }}>
        {columnIds.map(columnId => (
          <section
            key={columnId}
            style={{ flex: 1, minWidth: 0, padding: 8, borderRadius: 8, background: '#e8ecf2', color: '#111' }}
          >
            <h3 style={{ margin: '0 0 8px', fontSize: 16 }}>{columnNames[columnId]}</h3>
            <ul style={{ listStyle: 'none', margin: 0, padding: 0, minHeight: 40 }}>
              {board[columnId].map(id => (
                <li key={id} style={{ marginBottom: 8 }}>
                  <button
                    ref={el => {
                      if (el !== null) cardsRef.current.set(id, el)
                      return () => {
                        cardsRef.current.delete(id)
                      }
                    }}
                    aria-describedby="board-help"
                    onClick={() => pickOrDrop(id)}
                    onKeyDown={e => handleKeyDown(e, id)}
                    style={{
                      width: '100%',
                      padding: 8,
                      textAlign: 'left',
                      background: 'white',
                      color: '#111',
                      border: '1px solid #bbb',
                      borderRadius: 6,
                      boxShadow: picked?.id === id ? '0 0 0 3px royalblue' : undefined,
                    }}
                  >
                    {cardText[id]}
                  </button>
                </li>
              ))}
            </ul>
          </section>
        ))}
      </div>
      {picked !== null && (
        <div role="group" aria-label="Move the card">
          <button onClick={() => move('left')}>Move left</button>
          <button onClick={() => move('up')}>Move up</button>
          <button onClick={() => move('down')}>Move down</button>
          <button onClick={() => move('right')}>Move right</button>
          <button
            onClick={() => {
              const id = picked.id
              pickOrDrop(id)
              focusCard(id)
            }}
          >
            Drop
          </button>
          <button
            onClick={() => {
              const id = picked.id
              cancel()
              focusCard(id)
            }}
          >
            Cancel
          </button>
        </div>
      )}
      <p role="status">{message}</p>
    </div>
  )
}

Run it. Click into the board, or press Tab until “Write the plan” has the focus. Press Space. Then press the Right arrow, the Down arrow and Escape, and read the line at the bottom. Then try it with only the mouse: click a card, use the “Move” buttons, and click “Drop”.

We ran the keys in headless Chromium, Firefox and WebKit, with a real keyboard:

  • Tab went to “Write the plan”. Space picked it up: Picked up Write the plan. To do, position 1 of 3.
  • The Right arrow moved it to “Doing”, position 1 of 2, then the Down arrow to position 2 of 2. A second Down did nothing. The focus stayed on the card.
  • Escape put it back in “To do”, position 1 of 3. Enter worked like Space.
  • With only clicks, the card, “Move right” and “Drop” moved it to “Doing”. After “Drop”, the focus was on the card.
  • The page didn’t scroll on the arrow keys, because the handler calls e.preventDefault() for them (Part 47).

Here is how the code does it.

picked is the card in your hand, plus the board as it was when you picked it up. Escape puts that old board back. Undo is easy when every change makes a new board.

handleKeyDown only acts when the focused card is the picked one. Any other key reaches return before preventDefault, so Tab still works. Space and Enter are not in it at all. The button’s own click handles them.

nextPlace turns an arrow into a Place, with the same counting rule as before. It returns null at an edge, so nothing happens there.

aria-describedby links each card to the help text above the board. A screen reader reads it after the card’s name (Part 46).

The buttons are the single pointer way. They call the same move as the arrow keys. They don’t move the focus, because the button you just clicked is still there. Only “Drop” and “Cancel” move it to the card, because their buttons go away.

This board still leaves things out, and an interviewer may ask about them:

  • A click on another card while one is picked up drops the first one. It doesn’t pick up the second.
  • Escape works only while the focus is on the card. From the “Move” buttons, it does nothing.
  • An arrow key at an edge does nothing, and says nothing. A message like “Already at the top” would help.
  • “Doing is full.” twice in a row is the same text. Part 53 showed why a screen reader may not read it again.
  • Nothing on the card itself tells a screen reader that it is picked up. Only the message does. aria-pressed on the card is one way to show it.

We couldn’t check how each screen reader reads these messages. Only a real screen reader can show that (Part 46).

In a real app, you would put this and Step 4 together on one board. Both call moveCard, so the data side is easy. But there is one problem. Say you let go of a pointer drag over the same card you pressed. Then the browser can also fire a click on it. We logged clicks on Step 4’s cards. A drag that ended on the card’s own place, 20 pixels down, fired a click in all three browsers. So did a drag to the top of the same column. A drag into another column did not. On a board where a click picks a card up, that click would pick it up again.

The fix is a ref (Part 14). Set dragged.current = false on pointerdown. Set it to true once the pointer has moved more than a few pixels. In the card’s onClick, return early if dragged.current is true. We tried it with the same drags in all three browsers. Both clicks were ignored, and a plain click still picked a card. We kept the two steps apart so each one stays short.

Step 6: moving folders in the file explorer

Part 51 left one follow-up open: drag a file or folder into another folder. It said moving is three changes in the flat map, and one new moved action. And it warned of a trap. A folder must never move into itself, or into a folder inside it. Then the tree has a loop, and the recursion never stops.

Here is that move, with the same data shape and the same idsUnder as Part 51:

type FileNode = {
  id: string
  name: string
  kind: 'folder' | 'file'
  parentId: string | null
  childIds: string[]
}

type Nodes = Record<string, FileNode>

function idsUnder(nodes: Nodes, id: string): string[] {
  return [id, ...nodes[id].childIds.flatMap(childId => idsUnder(nodes, childId))]
}

function canMove(nodes: Nodes, id: string, targetId: string): boolean {
  if (nodes[targetId].kind !== 'folder') return false
  if (nodes[id].parentId === targetId) return false
  return !idsUnder(nodes, id).includes(targetId)
}

function moveNode(nodes: Nodes, id: string, targetId: string): Nodes {
  const node = nodes[id]
  if (node.parentId === null || !canMove(nodes, id, targetId)) return nodes
  const oldParent = nodes[node.parentId]
  const target = nodes[targetId]
  return {
    ...nodes,
    [oldParent.id]: { ...oldParent, childIds: oldParent.childIds.filter(c => c !== id) },
    [target.id]: { ...target, childIds: [...target.childIds, id] },
    [id]: { ...node, parentId: target.id },
  }
}

const start: Nodes = {
  root: { id: 'root', name: 'project', kind: 'folder', parentId: null, childIds: ['src', 'pkg'] },
  src: { id: 'src', name: 'src', kind: 'folder', parentId: 'root', childIds: ['ui', 'app'] },
  ui: { id: 'ui', name: 'components', kind: 'folder', parentId: 'src', childIds: ['btn'] },
  btn: { id: 'btn', name: 'Button.tsx', kind: 'file', parentId: 'ui', childIds: [] },
  app: { id: 'app', name: 'App.tsx', kind: 'file', parentId: 'src', childIds: [] },
  pkg: { id: 'pkg', name: 'package.json', kind: 'file', parentId: 'root', childIds: [] },
}

const results: string[] = []

function check(name: string, ok: boolean) {
  results.push((ok ? 'pass: ' : 'FAIL: ') + name)
}

const moved = moveNode(start, 'btn', 'root')
check('a file moves to the top', moved.root.childIds.join() === 'src,pkg,btn')
check('its old folder lets it go', moved.ui.childIds.length === 0)
check('it knows its new parent', moved.btn.parentId === 'root')
check('src cannot go into components, which is inside it', moveNode(start, 'src', 'ui') === start)
check('src cannot go into itself', moveNode(start, 'src', 'src') === start)
check('nothing goes into a file', moveNode(start, 'app', 'pkg') === start)
check('the root cannot move', moveNode(start, 'root', 'src') === start)
check('the old map did not change', start.ui.childIds.join() === 'btn')

export default function App() {
  return (
    <ul>
      {results.map(r => (
        <li key={r}>{r}</li>
      ))}
    </ul>
  )
}

Run it. All eight checks pass.

idsUnder(nodes, id) is the folder’s own id plus every id inside it. If the target is one of those, the move would make a loop, so canMove says no. That one line covers “into itself” and “into a folder inside it”. The root can’t move either: it has no parent, and every node is inside it.

Now take the includes check out. Press Edit and change the last line of canMove to return true. Run it. Two checks fail. Then the map has src inside components, which is inside src. Part 51 showed what that does: idsUnder throws RangeError: Maximum call stack size exceeded. We ran idsUnder on that broken map and got the same error.

Refusing the drop while you drag

In the tree, it’s better to refuse the drop before the user lets go. HTML drag and drop has a way. Some drop targets accept a drop only sometimes. For those, MDN’s guide says to “check a condition and only cancel the event when the condition is met”. So onDragOver calls preventDefault() only when canMove says yes. Over a bad folder, drop never fires.

But canMove needs the id of the dragged node. During dragover, getData can’t be trusted, as Step 2 measured. So we keep the id in a ref from dragstart. Here it is, with Part 51’s moved action in a reducer:

import { useReducer, useRef } from 'react'

type FileNode = {
  id: string
  name: string
  kind: 'folder' | 'file'
  parentId: string | null
  childIds: string[]
}

type Nodes = Record<string, FileNode>
type Action = { type: 'moved'; id: string; targetId: string }

function idsUnder(nodes: Nodes, id: string): string[] {
  return [id, ...nodes[id].childIds.flatMap(childId => idsUnder(nodes, childId))]
}

function canMove(nodes: Nodes, id: string, targetId: string): boolean {
  if (nodes[targetId].kind !== 'folder') return false
  if (nodes[id].parentId === targetId) return false
  return !idsUnder(nodes, id).includes(targetId)
}

function moveNode(nodes: Nodes, id: string, targetId: string): Nodes {
  const node = nodes[id]
  if (node.parentId === null || !canMove(nodes, id, targetId)) return nodes
  const oldParent = nodes[node.parentId]
  const target = nodes[targetId]
  return {
    ...nodes,
    [oldParent.id]: { ...oldParent, childIds: oldParent.childIds.filter(c => c !== id) },
    [target.id]: { ...target, childIds: [...target.childIds, id] },
    [id]: { ...node, parentId: target.id },
  }
}

function reducer(nodes: Nodes, action: Action): Nodes {
  switch (action.type) {
    case 'moved':
      return moveNode(nodes, action.id, action.targetId)
    default: {
      const missing: never = action.type
      throw new Error('Unknown action: ' + JSON.stringify(missing))
    }
  }
}

// Every node, top to bottom, with how deep it is.
function rows(nodes: Nodes, id: string, level: number): { id: string; level: number }[] {
  return [{ id, level }, ...nodes[id].childIds.flatMap(c => rows(nodes, c, level + 1))]
}

const firstNodes: Nodes = {
  root: { id: 'root', name: 'project', kind: 'folder', parentId: null, childIds: ['src', 'pkg'] },
  src: { id: 'src', name: 'src', kind: 'folder', parentId: 'root', childIds: ['ui', 'app'] },
  ui: { id: 'ui', name: 'components', kind: 'folder', parentId: 'src', childIds: ['btn'] },
  btn: { id: 'btn', name: 'Button.tsx', kind: 'file', parentId: 'ui', childIds: [] },
  app: { id: 'app', name: 'App.tsx', kind: 'file', parentId: 'src', childIds: [] },
  pkg: { id: 'pkg', name: 'package.json', kind: 'file', parentId: 'root', childIds: [] },
}

export default function App() {
  const [nodes, dispatch] = useReducer(reducer, firstNodes)
  const draggedId = useRef<string | null>(null)

  return (
    <ul style={{ listStyle: 'none', padding: 0, width: 240 }}>
      {rows(nodes, 'root', 0).map(({ id, level }) => (
        <li
          key={id}
          draggable={id !== 'root'}
          onDragStart={e => {
            draggedId.current = id
            e.dataTransfer.setData('text/plain', id)
          }}
          onDragOver={e => {
            if (draggedId.current !== null && canMove(nodes, draggedId.current, id)) e.preventDefault()
          }}
          onDrop={e => {
            e.preventDefault()
            if (draggedId.current !== null) dispatch({ type: 'moved', id: draggedId.current, targetId: id })
          }}
          onDragEnd={() => {
            draggedId.current = null
          }}
          style={{ padding: 4, paddingLeft: level * 16 + 4, cursor: id === 'root' ? 'default' : 'grab' }}
        >
          {nodes[id].name}
          {nodes[id].kind === 'folder' ? '/' : ''}
        </li>
      ))}
    </ul>
  )
}

The default case is Part 15’s exhaustive check, as in Part 51. One small change: with only one action type, TypeScript doesn’t narrow action itself. We tried const missing: never = action, and TypeScript said Type 'Action' is not assignable to type 'never'. So this check uses action.type.

Run it. A folder’s name ends in /. Every folder is open, to keep it short. Drag Button.tsx onto project/, and it moves to the top level. Then drag src/ onto components/. Nothing happens.

We did both drags in headless Chromium, Firefox and WebKit. Button.tsx moved to the end of project/. Over components/, the dragover was not cancelled, drop never fired, and the tree didn’t change. That was true in all three.

draggedId is a ref, not state. The page never shows it, and setting it must not cause a render (Part 14). onDragEnd clears it, whether the drop worked or not.

This tree is easier than Part 51’s, in one way that matters. Here every row is a plain <li> in one flat list. In Part 51’s tree, a folder’s <li> holds its children’s <li>s. Drag events bubble like clicks (Part 6). So a drag that starts on App.tsx would also reach the onDragStart of src. Then draggedId would end up as src. In that tree, put the drag handlers on each row’s own <div>, as Part 51 did for clicks. Or check e.target === e.currentTarget first.

This tree still needs a way to move without dragging, as Step 5 did, to meet WCAG 2.5.7. A “Move to” menu on each row is a common answer. It is like the guide’s first task board example: tap an item, then pick the new place from a menu.

What an interviewer looks for

  • A pure move function, tested first. It has one rule for the index. It gives back the same board for a move that changes nothing.
  • The dragover trap. You know that preventDefault() there is what makes a drop target.
  • A plan for touch. You know HTML drag and drop doesn’t work on some phone browsers. So you use pointer events, with setPointerCapture and touch-action.
  • A drop line from the pointer. It comes from the middles of the cards, and it moves nothing on the page.
  • A way without dragging. Keys, buttons for one click at a time, and a live region.
  • Keys from the data. Card ids, never the index (see Common mistakes).

Follow-up: why not use a library?

You can, and in a real app you often should. The dnd kit library supports “different input methods like mouse, touch, and keyboard”. It uses the keys from Step 5, and has its own live region. In an interview, though, they want to see that you understand what such a library does.

Follow-up: scrolling during a drag

Our board reads the column edges once, on pointerdown. If the page or a column scrolls during the drag, those numbers are wrong. One fix is to read them again on scroll. Another is to scroll the column yourself when the pointer is near its edge. Then update the numbers as you scroll.

Follow-up: saving the order to a server

Change the board at once, then send the move to the server. If the server says no, put the old board back. That is an optimistic update, from Part 30. Send the move as { id, columnId, index }, not the whole board.

Common mistakes

No preventDefault() in onDragOver

You saw it in “Try this first”. Without it, drop never fires. We counted the events while dragging the card into the right box. dragover fired 9 or 10 times in each browser, and drop fired 0 times. The fix is one line: onDragOver={e => e.preventDefault()} on every drop target.

Changing the arrays in state

This moves a card by changing the arrays that are already in state:

import { useState } from 'react'

type Board = { todo: string[]; done: string[] }

export default function App() {
  const [board, setBoard] = useState<Board>({
    todo: ['Write the plan', 'Draw the screens', 'Build the board'],
    done: [],
  })

  function moveFirst() {
    setBoard(b => {
      const [first] = b.todo.splice(0, 1)
      b.done.push(first)
      return { ...b }
    })
  }

  return (
    <div>
      <button onClick={moveFirst}>Move the first card to Done</button>
      <p>To do: {board.todo.join(', ')}</p>
      <p>Done: {board.done.join(', ')}</p>
    </div>
  )
}

Run it and click once. Two cards move to “Done”, not one.

Strict Mode calls an updater function twice in development, to find code like this (Part 5). Both calls get the same b. The first call takes “Write the plan” out of that array. The second call takes the next card out of the same, already changed array. We checked: with Strict Mode off, one click moved one card. But the old state was still changed in place. That would break Escape in Step 5, which puts the old board back.

The fix is moveCard: toSpliced and spread make new arrays, and the old ones stay as they were.

The index as the key

Each card here keeps its own state: a star you can turn on.

import { useState } from 'react'

function Card({ text }: { text: string }) {
  const [starred, setStarred] = useState(false)
  return (
    <li>
      <button onClick={() => setStarred(!starred)}>
        {starred ? '★' : '☆'} {text}
      </button>
    </li>
  )
}

export default function App() {
  const [cards, setCards] = useState(['Write the plan', 'Draw the screens', 'Build the board'])

  return (
    <div>
      <button onClick={() => setCards([cards[cards.length - 1], ...cards.slice(0, -1)])}>
        Move the last card to the top
      </button>
      <ul>
        {cards.map((text, index) => (
          <Card key={index} text={text} />
        ))}
      </ul>
    </div>
  )
}

Run it. Star “Write the plan”. Then move the last card to the top. Now “Build the board” has the star, and “Write the plan” has lost it.

The star belongs to key 0. After the move, “Build the board” is at index 0. So it gets key 0, and the star with it. That is the bug from Part 8. Every drag changes the order of a list, so a board meets this bug on every move. Use the card’s id: here key={text} works, because the texts are all different.

Practice

Press Edit on the app in Step 5 and try these.

  1. Add Home and End. Home moves the picked-up card to the top of its column, and End to the bottom.
  2. Add console.log('moveCard') as the first line of moveCard. Pick up “Write the plan” and press the Right arrow once. How many lines does the Console show? Now change flushSync(() => setBoard(next)) to flushSync(() => setBoard(b => moveCard(b, from, to))). How many now?
  3. Let “Doing” hold at most 2 cards. A move into a full “Doing” does nothing, and the bottom line says Doing is full.
  4. In the Step 1 checks, add one more check. Move the only card in “Doing” to “Doing”, index 0. Check that you get the same board back.
Answers

1. First, a function that gives the top or the bottom place:

type ColumnId = 'todo' | 'doing' | 'done'
type Board = Record<ColumnId, string[]>
type Place = { columnId: ColumnId; index: number }

function edgePlace(board: Board, from: Place, key: string): Place {
  return { ...from, index: key === 'Home' ? 0 : board[from.columnId].length - 1 }
}

Put edgePlace at the top level, next to nextPlace. Then add this branch to handleKeyDown, just before the Escape branch:

    } else if (e.key === 'Home' || e.key === 'End') {
      const from = placeOf(board, id)
      if (from === null) return
      const next = moveCard(board, from, edgePlace(board, from, e.key))
      flushSync(() => setBoard(next))
      setMessage(`${cardText[id]}: ${describe(next, id)}.`)

It reads like move, with edgePlace in place of nextPlace. In our test, End on “Write the plan” gave To do, position 3 of 3, and Home put it back at position 1.

2. One line. move calls moveCard once, in the event handler. Strict Mode doesn’t run event handlers twice. With the updater function, the Console shows three lines. const next = moveCard(board, from, to) still runs once, for the message. Then Strict Mode calls the updater function twice, as the mistake above showed. With Strict Mode off, it shows two. Either way the card moves by one place, because moveCard is pure.

3. In move, after nextPlace, add a check before moveCard:

type ColumnId = 'todo' | 'doing' | 'done'
type Board = Record<ColumnId, string[]>
type Place = { columnId: ColumnId; index: number }

function isFull(board: Board, from: Place, to: Place): boolean {
  return to.columnId === 'doing' && from.columnId !== 'doing' && board.doing.length >= 2
}

If isFull(board, from, to) is true, call setMessage('Doing is full.') and return. The check from.columnId !== 'doing' matters. Without it, a card already in a full “Doing” couldn’t move up or down. In our test, “Write the plan” moved into “Doing” with one card. Then “Draw the screens” was refused. The bottom line said Doing is full. and the card stayed in “To do”. “Test on a phone” could still move up inside the full “Doing”.

4. check('the only card stays put', moveCard(start, { columnId: 'doing', index: 0 }, { columnId: 'doing', index: 0 }) === start). It passes. The first line of moveCard sees the same place and returns board itself.

Interview questions

Try to answer each one out loud before you open the answer.

Why does a drop handler never run, even though the element has one?

By default, most elements are not drop targets. An element becomes one when its dragover event is cancelled with preventDefault(). Without that, the browser treats the place as “no drop here”, and drop never fires. So every drop target needs onDragOver={e => e.preventDefault()}. A strong answer adds that you can call it only sometimes, to refuse some drops. Step 6 does that for a folder that can’t take the drop.

HTML drag and drop or pointer events: which would you use?

HTML drag and drop is built in and short to write. It can also take files and text from outside the page. But you can’t style the dragged picture much. The data may be hidden until drop. And caniuse.com lists some phone browsers with no support. Pointer events take more code, but work the same for mouse, pen and finger. With setPointerCapture, the card keeps getting events even when the pointer leaves it. touch-action: none stops the browser from scrolling instead. For a board that must work on phones, pointer events. For files dropped from the computer, HTML drag and drop.

How do you work out where the card will land?

On pointerdown, read the edges of each column and the middle of every other card, with getBoundingClientRect(). On each move, the column is the one that holds the pointer’s x. The index is how many of the other cards have their middle above the pointer’s y. Leave the dragged card out, so the index means “where it ends up”. A strong answer draws the line with something that takes no room, like a shadow. Then the cards don’t move under the pointer. It also says when to read the numbers again, such as after a scroll.

What makes drag and drop accessible?

Three things. First, a keyboard way. Space or Enter picks a card up, the arrows move it, and Space or Enter drops it. Escape cancels. That covers WCAG 2.1.1. A way with single clicks or taps, like “Move” buttons or a menu. That covers WCAG 2.5.7, which a keyboard way alone doesn’t meet. And a live region that says what happened, in positions like “position 2 of 3”. A strong answer also keeps the focus on the card after it moves to another column. Its old element is gone.

A keyboard user moves a card to another column, and the focus is lost. Why, and how do you fix it?

The card is in a different list now, so React removes its old element and makes a new one. The old element had the focus. Inside one column, the element stays, and React focuses it again after its update. In a new column there is nothing for React to focus again. The fix is to focus the card again after React updates the page. Keep the elements in a ref Map. Wrap the state change in flushSync, and call focus() on the next line. An Effect that runs after the update also works.

How do you stop a folder from being dropped into itself?

Collect the folder’s own id and every id under it, with a recursive function like idsUnder. If the target is in that list, refuse the move. That catches “into itself” and “into a folder inside it”. Without the check, the tree gets a loop, and any recursive walk of it throws Maximum call stack size exceeded. A strong answer checks twice. onDragOver checks, so the drop is refused while you drag. The reducer checks too, so no other path can make a loop.

Sources

  • HTML Drag and Drop API, MDN: draggable, the drag events, “By default, most elements are not drop targets”, cancelling dragover, when the drag data can be read, and “dragging data into a page”.
  • Drag operations, MDN: conditional drop targets, “check a condition and only cancel the event when the condition is met”.
  • MDN: dragover, drop and DataTransfer.getData().
  • Drag and Drop, caniuse.com: support on Safari for iPhone and iPad (from 15), Chrome for Android, Firefox for Android and Samsung Internet.
  • MDN: Pointer events, setPointerCapture() (“the capture target of future pointer events”), pointercancel, MouseEvent.button (“Main button”), isPrimary on the Pointer events page, and touch-action.
  • Understanding SC 2.5.7: Dragging Movements, W3C: the rule’s words, why a keyboard way is not enough on its own, and the two task board examples (a pop-up menu, and arrow buttons).
  • Understanding SC 2.1.1: Keyboard, W3C: the exception for input that depends on the path, not just the endpoints.
  • dnd kit (legacy React docs): Keyboard sensor and Accessibility: the Space, Enter, arrow and Escape keys, and “use positions rather than indices”. dnd kit: “Sensors”.
  • react.dev: Updating Arrays in State, Rendering Lists (keys), useState (updater functions run twice in Strict Mode), memo and Manipulating the DOM with Refs (flushSync, and a ref for each item).
  • React’s own code, react-dom-client.development.js in react-dom 19.3.0: after a commit, it focuses the element that had the focus before, if it is still on the page.
  • The checks, dropPlace and the mistakes come from running React 19.3.0 for this post. The drags, touches, keys, focus and render counts come from headless Chromium, Firefox and WebKit.
  • This part follows the Drag & Drop (Kanban) kata in react-katas. The kata uses HTML drag and drop. This part adds the pure move function, pointer events, the drop line, the keyboard and the file explorer.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.