# How to Build a Performant and Accessible Drag and Drop React UI Builder

> Build a performant accessible drag and drop React UI builder using synthetic drag events memoization refs and ARIA attributes Handle thousands of items smoothly with screen reader support

- Repository: [Meta/react](https://github.com/facebook/react)
- Tags: best-practices
- Published: 2026-02-16

---

**Use React’s synthetic drag events, memoize components with `React.memo`, store transient coordinates in `useRef`, and implement ARIA attributes like `aria-grabbed` and keyboard handlers to create a drag and drop React UI builder that handles thousands of items without jank and works with screen readers.**

Building a drag and drop React UI builder requires balancing smooth interactions with inclusive design. The React source code in the `facebook/react` repository provides a robust foundation through its event system and ARIA property validation, enabling developers to implement native HTML5 drag and drop while maintaining React’s declarative patterns.

## Leveraging React’s Synthetic Event System for Native Drag and Drop

React normalizes browser inconsistencies by wrapping native events in synthetic event objects. For drag operations, this means you can rely on consistent behavior across Chrome, Safari, and Firefox without manual polyfills.

### How SimpleEventPlugin Normalizes Drag Events

In [`packages/react-dom-bindings/src/events/plugins/SimpleEventPlugin.js`](https://github.com/facebook/react/blob/main/packages/react-dom-bindings/src/events/plugins/SimpleEventPlugin.js), React registers native drag events—including `drag`, `dragstart`, `dragend`, `dragenter`, `dragleave`, `dragover`, and `drop`—and maps them to the `SyntheticDragEvent` constructor. This plugin ensures that when you attach handlers like `onDragStart` or `onDrop` to JSX elements, React manages the event pooling and propagation consistently.

The `draggable` attribute itself is validated by React’s internal property mapping. In [`packages/react-dom-bindings/src/shared/possibleStandardNames.js`](https://github.com/facebook/react/blob/main/packages/react-dom-bindings/src/shared/possibleStandardNames.js), the attribute is listed among standard HTML properties, ensuring that `<div draggable />` renders correctly without casing warnings.

## Implementing Accessibility in Your Drag and Drop React UI Builder

Accessibility is non-negotiable for UI builders used in enterprise or public-sector applications. React’s support for ARIA properties allows you to communicate drag state to assistive technologies using standard HTML attributes.

### Essential ARIA Attributes for Drag Operations

React’s [`packages/react-dom-bindings/src/shared/validAriaProperties.js`](https://github.com/facebook/react/blob/main/packages/react-dom-bindings/src/shared/validAriaProperties.js) explicitly lists drag-and-drop-related ARIA properties that the framework will forward to the DOM. These include:

- **`aria-grabbed`**: Indicates whether an element is currently being dragged. Set this to `"true"` in `onDragStart` and `"false"` in `onDragEnd`.
- **`aria-dropeffect`**: Describes the allowed drop actions (`copy`, `move`, `link`, or `none`). Apply this to drop zones to inform screen readers of available operations.
- **`aria-describedby`** or **`aria-label`**: Provide textual context for draggable items so users understand what they are moving.

Assign semantic roles such as `role="option"`, `role="listitem"`, or `role="treeitem"` to draggable elements to ensure they are identified as interactive components within lists or trees.

### Keyboard Navigation Support

Native drag and drop relies on pointer devices, so you must implement keyboard alternatives. Add `tabIndex={0}` to draggable items and handle `onKeyDown` events:

- **Enter** or **Space**: Toggle drag mode (pick up or drop).
- **Arrow keys**: Move the item within the list or between containers.

This approach avoids expensive mouse event calculations and ensures compliance with WCAG 2.1 Level AA requirements.

## Performance Optimization Strategies

A drag and drop React UI builder handling hundreds or thousands of components requires aggressive optimization to maintain 60fps during interactions.

### Preventing Unnecessary Re-renders with React.memo

Wrap each draggable component with `React.memo` to prevent re-renders when parent state changes but the item’s props remain constant. Combine this with `useCallback` for event handlers:

```typescript
const handleDragStart = useCallback((e: React.DragEvent) => {
  // drag logic
}, [id, index]);

```

This ensures that the function reference remains stable across renders, allowing `React.memo` to perform shallow prop comparison effectively.

### Managing Transient State with useRef and requestAnimationFrame

Drag operations generate high-frequency events (`dragover` fires continuously). Storing coordinates or hover indices in state causes excessive re-renders. Instead:

1. Store transient data in a `useRef` object.
2. Update visual feedback (ghost elements, insertion indicators) inside a `requestAnimationFrame` callback.
3. Only call `setState` when the drop actually occurs or when the hover index changes significantly.

This pattern limits React’s reconciliation work to roughly 60fps and prevents layout thrashing.

### Virtualization for Large Datasets

When rendering lists exceeding 50–100 items, use virtualization libraries like `react-window` or `react-virtualized`. These render only visible items, reducing DOM node count and event listener overhead. Wrap your `DraggableItem` components inside the virtualizer’s row renderer, ensuring that drag handlers are attached only to mounted elements.

### Computing Drop Targets Efficiently

Complex drop logic (determining insertion indices or valid drop zones) can block the main thread. Move these calculations into pure functions outside the component tree and memoize results with `useMemo`:

```typescript
const dropIndex = useMemo(() => 
  calculateDropIndex(items, mouseY), 
  [items, mouseY]
);

```

This keeps heavy algorithms out of the render path and allows React to skip recomputation when dependencies are unchanged.

## Architectural Patterns for a Drag and Drop React UI Builder

A maintainable implementation separates concerns into three layers: the draggable item, the container managing state, and the accessibility announcer.

### The DraggableItem Component

This presentational component handles individual item interactions. It receives callbacks for drag start/end and manages local ARIA states:

```tsx
import React, {useCallback, useRef, memo} from 'react';

type Props = {
  id: string;
  index: number;
  isDragging: boolean;
  onDragStart: (id: string, index: number) => void;
  onDragEnd: () => void;
};

const DraggableItem = memo(
  ({id, index, isDragging, onDragStart, onDragEnd}: Props) => {
    const handleDragStart = useCallback(
      (e: React.DragEvent) => {
        e.dataTransfer.effectAllowed = 'move';
        e.dataTransfer.setData('text/plain', JSON.stringify({id, index}));
        onDragStart(id, index);
        e.currentTarget.setAttribute('aria-grabbed', 'true');
      },
      [id, index, onDragStart],
    );

    const handleDragEnd = useCallback(
      (e: React.DragEvent) => {
        onDragEnd();
        e.currentTarget.setAttribute('aria-grabbed', 'false');
      },
      [onDragEnd],
    );

    return (
      <div
        role="option"
        draggable
        aria-grabbed={isDragging}
        aria-label={`Item ${id}`}
        tabIndex={0}
        onDragStart={handleDragStart}
        onDragEnd={handleDragEnd}
        onKeyDown={(e) => {
          if (e.key === 'Enter' || e.key === ' ') {
            isDragging ? onDragEnd() : onDragStart(id, index);
          }
        }}
        style={{
          opacity: isDragging ? 0.5 : 1,
          padding: '8px',
          marginBottom: '4px',
          border: '1px solid #ddd',
          background: '#fff',
          cursor: 'move',
        }}
      >
        {`Item ${id}`}
      </div>
    );
  },
);

export default DraggableItem;

```

### The DragDropList Container

The container manages the collection state and coordinates virtualization. It uses `react-window` to render only visible items:

```tsx
import React, {useState, useCallback, useRef} from 'react';
import {FixedSizeList as List} from 'react-window';
import DraggableItem from './DraggableItem';

type Item = {id: string; content: string};

export default function DragDropList({initialItems}: {initialItems: Item[]}) {
  const [items, setItems] = useState(initialItems);
  const [draggingId, setDraggingId] = useState<string | null>(null);
  const dragIndexRef = useRef<number>(-1);

  const onDragStart = useCallback((id: string, index: number) => {
    setDraggingId(id);
    dragIndexRef.current = index;
  }, []);

  const onDragEnd = useCallback(() => {
    setDraggingId(null);
    dragIndexRef.current = -1;
  }, []);

  const onDrop = useCallback(
    (e: React.DragEvent) => {
      e.preventDefault();
      const payload = JSON.parse(e.dataTransfer.getData('text/plain'));
      const fromIdx = payload.index;
      const toIdx = Number(e.currentTarget.dataset.index);
      if (fromIdx === toIdx) return;
      setItems(prev => {
        const updated = [...prev];
        const [moved] = updated.splice(fromIdx, 1);
        updated.splice(toIdx, 0, moved);
        return updated;
      });
      onDragEnd();
    },
    [onDragEnd],
  );

  const Row = ({index, style}: {index: number; style: React.CSSProperties}) => {
    const item = items[index];
    return (
      <div
        style={style}
        data-index={index}
        onDragOver={e => e.preventDefault()}
        onDrop={onDrop}
      >
        <DraggableItem
          id={item.id}
          index={index}
          isDragging={draggingId === item.id}
          onDragStart={onDragStart}
          onDragEnd={onDragEnd}
        />
      </div>
    );
  };

  return (
    <List
      height={400}
      itemCount={items.length}
      itemSize={48}
      width="100%"
    >
      {Row}
    </List>
  );
}

```

### Live Region Announcements

Screen readers require explicit confirmation of state changes. Implement a live region component to announce drop operations:

```tsx
import React, {useState, useEffect} from 'react';

export const DropAnnouncer = ({message}: {message: string}) => {
  const [text, setText] = useState('');
  useEffect(() => {
    setText(message);
    const timer = setTimeout(() => setText(''), 2000);
    return () => clearTimeout(timer);
  }, [message]);

  return (
    <div
      aria-live="assertive"
      aria-atomic="true"
      style={{position: 'absolute', left: '-9999px'}}
    >
      {text}
    </div>
  );
};

```

Mount `DropAnnouncer` inside `DragDropList` and update its `message` prop after each successful reorder to notify assistive technologies of the new arrangement.

## Common Pitfalls and How to Avoid Them

Even experienced developers encounter specific friction points when implementing drag and drop in React. Avoid these mistakes to maintain both performance and accessibility.

- **Relying on `event.clientX/Y` for positioning in the render phase**: Reading mouse coordinates during `dragover` and immediately calling `setState` creates render storms. Instead, store coordinates in a `useRef` and only update visual feedback inside `requestAnimationFrame`.

- **Forgetting to set `draggable` to `true`**: React’s `possibleStandardNames` mapping in [`packages/react-dom-bindings/src/shared/possibleStandardNames.js`](https://github.com/facebook/react/blob/main/packages/react-dom-bindings/src/shared/possibleStandardNames.js) ensures the attribute renders correctly, but you must explicitly include `draggable` on the element. Omitting it prevents native drag initiation.

- **Not updating `aria-grabbed` when drag ends**: Screen readers remain in the "grabbed" state if you fail to reset `aria-grabbed` to `"false"` in the `onDragEnd` handler. Always synchronize ARIA state with the actual drag state.

- **Heavy state updates on every `dragover`**: Store the current hover index in a ref and only invoke `setState` when the index actually changes. This prevents React from reconciling the entire list on every mouse movement.

- **Missing keyboard support**: Native HTML5 drag and drop does not support keyboard operation. You must implement `tabIndex={0}` and handle `Enter`, `Space`, and arrow keys to provide equivalent functionality for non-mouse users.

## Summary

Building a drag and drop React UI builder that scales requires combining React’s internal event normalization with disciplined performance patterns:

- **Use React’s synthetic events**: The `SimpleEventPlugin` in [`packages/react-dom-bindings/src/events/plugins/SimpleEventPlugin.js`](https://github.com/facebook/react/blob/main/packages/react-dom-bindings/src/events/plugins/SimpleEventPlugin.js) provides consistent `SyntheticDragEvent` objects across browsers.
- **Implement ARIA attributes**: Leverage `aria-grabbed`, `aria-dropeffect`, and roles validated by [`packages/react-dom-bindings/src/shared/validAriaProperties.js`](https://github.com/facebook/react/blob/main/packages/react-dom-bindings/src/shared/validAriaProperties.js) to ensure screen reader compatibility.
- **Optimize rendering**: Wrap items in `React.memo`, store transient drag data in `useRef`, and batch visual updates with `requestAnimationFrame`.
- **Virtualize large lists**: Use libraries like `react-window` to mount only visible items, reducing DOM overhead.
- **Support keyboard interaction**: Provide `tabIndex` and `onKeyDown` handlers as alternatives to mouse-based dragging.

## Frequently Asked Questions

### How does React handle native HTML5 drag events differently than standard DOM events?

React normalizes native drag events through the `SimpleEventPlugin` located in [`packages/react-dom-bindings/src/events/plugins/SimpleEventPlugin.js`](https://github.com/facebook/react/blob/main/packages/react-dom-bindings/src/events/plugins/SimpleEventPlugin.js). This plugin registers all drag-related events and instantiates `SyntheticDragEvent` objects, which provide a consistent interface across browsers while pooling event objects for performance. You interact with these through standard React props like `onDragStart` and `onDrop` rather than attaching native listeners manually.

### What ARIA attributes are essential for making drag and drop operations accessible?

According to [`packages/react-dom-bindings/src/shared/validAriaProperties.js`](https://github.com/facebook/react/blob/main/packages/react-dom-bindings/src/shared/validAriaProperties.js), React supports `aria-grabbed` and `aria-dropeffect` specifically for drag-and-drop interfaces. You should set `aria-grabbed="true"` when an item is being dragged and `aria-dropeffect="move"` (or `copy`/`link`) on valid drop zones. Additionally, use `role="option"` or `role="listitem"` on draggable elements and include `aria-label` or `aria-describedby` to provide context about what is being moved.

### How can I prevent performance degradation when dragging items in a large list?

Avoid calling `setState` during high-frequency events like `dragover`. Instead, store the current drag position or hover index in a `useRef`, and only update React state when the drop actually occurs or when the hover target changes significantly. For lists with more than 50 items, implement virtualization using `react-window` or `react-virtualized` to render only visible items. Wrap individual items with `React.memo` and use `useCallback` for all drag handlers to prevent unnecessary re-renders of the list during drag operations.

### Why is keyboard support necessary if HTML5 drag and drop already works with mouse and touch?

Native HTML5 drag and drop does not provide built-in keyboard operability, which violates WCAG 2.1 Level AA requirements for keyboard accessibility. Users who rely on screen readers, switch devices, or cannot use pointing devices require alternative input methods. Implement `tabIndex={0}` on draggable items and handle `Enter` or `Space` to initiate dragging, then use arrow keys to navigate between drop zones and `Enter`/`Space` again to complete the drop. This dual-mode interaction ensures your drag and drop React UI builder is usable by everyone.