DEV Community

AYUSH SRIVASTAVA
AYUSH SRIVASTAVA

Posted on

Why do clicks feel slow in React and how can UI feel instant?

Problem

Most interfaces wait for the server before showing changes, so users stare at spinners and assume the app is lagging even on high‑confidence actions like likes, toggles, and comments. Rendering every update as urgent also blocks interactions on large trees or slower devices, creating visible jank.

Solution

Use React’s useOptimistic to render a predicted state immediately (for example, show a “Sending…” message) while the async action runs in the background. Wrap the final commit in startTransition so non‑urgent reconciliation stays responsive and doesn’t block higher‑priority interactions.

Minimal code


import { useState, useOptimistic, startTransition, useRef } from 'react';

// Simulated server action
async function sendMessageToServer(text: string) {
  await new Promise((r) => setTimeout(r, 800));
  // throw new Error('Network down'); // uncomment to test rollback
  return { id: crypto.randomUUID(), text };
}

export default function Chat() {
  const formRef = useRef<HTMLFormElement>(null);
  const [messages, setMessages] = useState<{ id: string; text: string }[]>([]);

  // Merge optimistic message at the top while pending
  const [optimisticMessages, addOptimisticMessage] = useOptimistic(
    messages,
    (current, optimistic: { id: string; text: string }) => [
      { ...optimistic, pending: true } as any,
      ...current,
    ]
  );

  async function onSubmit(formData: FormData) {
    const text = String(formData.get('text') || '').trim();
    if (!text) return;

    const tempId = `temp-${Date.now()}`;

    // 1) Show instantly
    addOptimisticMessage({ id: tempId, text });
    formRef.current?.reset();

    try {
      // 2) Reconcile in a transition
      startTransition(async () => {
        const saved = await sendMessageToServer(text);
        setMessages((prev) => [
          { id: saved.id, text: saved.text },
          ...prev.filter(m => m.id !== tempId),
        ]);
      });
    } catch {
      // 3) Rollback on error
      startTransition(() => {
        setMessages((prev) => prev.filter(m => m.id !== tempId));
      });
      // Optionally trigger a toast or error state here
    }
  }

  return (
    <div>
      <form ref={formRef} action={(fd) => onSubmit(fd)}>
        <input name="text" placeholder="Write a message..." />
        <button type="submit">Send</button>
      </form>

      <ul>
        {optimisticMessages.map((m: any) => (
          <li key={m.id}>
            {m.text} {m.pending ? <em style={{ color: '#888' }}> (Sending)</em> : null}
          </li>
        ))}
      </ul>
    </div>
  );
}


Enter fullscreen mode Exit fullscreen mode

When to use

  • High‑success, reversible actions: messages, likes, toggles, reorders, comments.
  • Avoid for destructive/high‑stakes operations unless paired with confirmation and clear undo.

Gotchas

  • startTransition is for prioritization; useTransition provides isPending if a pending indicator is required.
  • After an awaited call, wrap subsequent setState in startTransition to keep it non‑urgent.
  • Keep controlled inputs outside transitions, and ensure server actions are idempotent or de‑duplicated for safe retries.

Takeaway

Optimistic rendering reduces perceived latency, while transition‑based reconciliation prevents non‑urgent work from blocking interactions—resulting in UI that feels instant without sacrificing correctness.

Top comments (0)