Skip to main content

Optimistic UI

Optimistic UI updates the interface immediately when a user takes an action, before waiting for the server response. This creates a faster, more responsive user experience.

Basic Concept

Instead of waiting for the server:
// Traditional: Wait for server
// 1. User clicks "Like"
// 2. Show loading spinner
// 3. Wait for server response
// 4. Update UI

// Optimistic: Update immediately
// 1. User clicks "Like"
// 2. Immediately show liked state
// 3. Send request to server in background
// 4. Rollback if it fails

Using useFetcher

The most common way to implement optimistic UI:
import { useFetcher } from "react-router";

export default function LikeButton({ post }) {
  const fetcher = useFetcher();
  
  // Optimistic state
  const liked = fetcher.formData
    ? fetcher.formData.get("liked") === "true"
    : post.liked;
  
  return (
    <fetcher.Form method="post" action="/api/like">
      <input type="hidden" name="postId" value={post.id} />
      <input type="hidden" name="liked" value={(!liked).toString()} />
      <button type="submit" className={liked ? "liked" : ""}>
        {liked ? "♥" : "♡"} {post.likes}
      </button>
    </fetcher.Form>
  );
}

Optimistic Lists

Add items to lists immediately:
import { useFetcher } from "react-router";

export default function TodoList({ todos }) {
  const fetcher = useFetcher();
  
  // Show new todo immediately
  const optimisticTodos = fetcher.formData
    ? [
        ...todos,
        {
          id: crypto.randomUUID(),
          text: fetcher.formData.get("text"),
          completed: false,
          pending: true, // Mark as pending
        },
      ]
    : todos;
  
  return (
    <div>
      <ul>
        {optimisticTodos.map((todo) => (
          <li
            key={todo.id}
            className={todo.pending ? "pending" : ""}
          >
            {todo.text}
          </li>
        ))}
      </ul>
      
      <fetcher.Form method="post">
        <input type="text" name="text" required />
        <button type="submit">Add Todo</button>
      </fetcher.Form>
    </div>
  );
}

Optimistic Updates

Update items immediately:
import { useFetcher } from "react-router";

export default function TodoItem({ todo }) {
  const fetcher = useFetcher();
  
  // Optimistic completion state
  const completed = fetcher.formData
    ? fetcher.formData.get("completed") === "true"
    : todo.completed;
  
  return (
    <li className={completed ? "completed" : ""}>
      <fetcher.Form method="post" action="/todos/update">
        <input type="hidden" name="id" value={todo.id} />
        <input
          type="hidden"
          name="completed"
          value={(!completed).toString()}
        />
        <button type="submit">
          {completed ? "✓" : "○"}
        </button>
      </fetcher.Form>
      <span>{todo.text}</span>
    </li>
  );
}

Optimistic Deletes

Remove items immediately:
import { useFetcher } from "react-router";

export default function TaskList({ tasks }) {
  const fetcher = useFetcher();
  
  // Filter out deleted items
  const optimisticTasks = tasks.filter((task) => {
    const isDeleting =
      fetcher.formData?.get("id") === task.id &&
      fetcher.formData?.get("intent") === "delete";
    return !isDeleting;
  });
  
  return (
    <ul>
      {optimisticTasks.map((task) => (
        <li key={task.id}>
          {task.name}
          <fetcher.Form method="post" style={{ display: "inline" }}>
            <input type="hidden" name="id" value={task.id} />
            <input type="hidden" name="intent" value="delete" />
            <button type="submit">Delete</button>
          </fetcher.Form>
        </li>
      ))}
    </ul>
  );
}

Using useFetchers

Track multiple fetchers for complex UIs:
import { useFetchers } from "react-router";

export default function CommentList({ comments }) {
  const fetchers = useFetchers();
  
  // Gather all optimistic comments
  const optimisticComments = [...comments];
  
  for (const fetcher of fetchers) {
    if (fetcher.formData?.get("intent") === "create") {
      optimisticComments.push({
        id: crypto.randomUUID(),
        text: fetcher.formData.get("text"),
        author: fetcher.formData.get("author"),
        pending: true,
      });
    }
  }
  
  return (
    <div>
      {optimisticComments.map((comment) => (
        <div key={comment.id} className={comment.pending ? "pending" : ""}>
          <p>{comment.text}</p>
          <small>by {comment.author}</small>
        </div>
      ))}
    </div>
  );
}

Multiple Operations

Handle different intents:
import { useFetcher } from "react-router";

export default function Product({ product }) {
  const fetcher = useFetcher();
  
  const intent = fetcher.formData?.get("intent");
  
  // Optimistic quantity
  const quantity =
    intent === "increment"
      ? product.quantity + 1
      : intent === "decrement"
      ? product.quantity - 1
      : product.quantity;
  
  return (
    <div>
      <h2>{product.name}</h2>
      <p>Quantity: {quantity}</p>
      
      <fetcher.Form method="post">
        <input type="hidden" name="id" value={product.id} />
        <button type="submit" name="intent" value="decrement">
          -
        </button>
        <button type="submit" name="intent" value="increment">
          +
        </button>
      </fetcher.Form>
    </div>
  );
}

Error Handling

Show errors and restore original state:
import { useFetcher } from "react-router";

export default function SaveButton({ data }) {
  const fetcher = useFetcher();
  
  return (
    <div>
      <fetcher.Form method="post">
        <input type="text" name="title" defaultValue={data.title} />
        <button type="submit">
          {fetcher.state === "submitting" ? "Saving..." : "Save"}
        </button>
      </fetcher.Form>
      
      {fetcher.data?.error && (
        <div className="error">
          {fetcher.data.error}
          <button onClick={() => fetcher.reset()}>Retry</button>
        </div>
      )}
      
      {fetcher.data?.success && (
        <div className="success">Saved!</div>
      )}
    </div>
  );
}

Loading States

Provide visual feedback:
import { useFetcher } from "react-router";

export default function FollowButton({ user }) {
  const fetcher = useFetcher();
  
  const following = fetcher.formData
    ? fetcher.formData.get("following") === "true"
    : user.following;
  
  const isSubmitting = fetcher.state === "submitting";
  
  return (
    <fetcher.Form method="post" action="/follow">
      <input type="hidden" name="userId" value={user.id} />
      <input type="hidden" name="following" value={(!following).toString()} />
      <button
        type="submit"
        disabled={isSubmitting}
        className={following ? "following" : ""}
      >
        {isSubmitting
          ? "..."
          : following
          ? "Following"
          : "Follow"}
      </button>
    </fetcher.Form>
  );
}

Revalidation

Combine optimistic updates with revalidation:
export async function action({ request }: Route.ActionArgs) {
  const formData = await request.formData();
  const id = formData.get("id");
  
  await db.task.update({
    where: { id },
    data: { completed: formData.get("completed") === "true" },
  });
  
  // Return updated data
  return { success: true };
}

// Component shows optimistic state immediately
// After action completes, loader reruns and updates with real data

Complex Example: Shopping Cart

import { useFetcher, useFetchers } from "react-router";

export default function ShoppingCart({ items }) {
  const fetchers = useFetchers();
  
  // Calculate optimistic cart state
  let optimisticItems = [...items];
  
  for (const fetcher of fetchers) {
    const intent = fetcher.formData?.get("intent");
    const productId = fetcher.formData?.get("productId");
    
    if (intent === "add") {
      const existing = optimisticItems.find((i) => i.id === productId);
      if (existing) {
        existing.quantity += 1;
      } else {
        optimisticItems.push({
          id: productId,
          name: fetcher.formData.get("name"),
          quantity: 1,
          price: Number(fetcher.formData.get("price")),
          pending: true,
        });
      }
    }
    
    if (intent === "remove") {
      optimisticItems = optimisticItems.filter((i) => i.id !== productId);
    }
    
    if (intent === "updateQuantity") {
      const item = optimisticItems.find((i) => i.id === productId);
      if (item) {
        item.quantity = Number(fetcher.formData.get("quantity"));
      }
    }
  }
  
  const total = optimisticItems.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  );
  
  return (
    <div>
      <h2>Cart</h2>
      {optimisticItems.map((item) => (
        <CartItem key={item.id} item={item} />
      ))}
      <p>Total: ${total.toFixed(2)}</p>
    </div>
  );
}

function CartItem({ item }) {
  const fetcher = useFetcher();
  
  return (
    <div className={item.pending ? "pending" : ""}>
      <span>{item.name}</span>
      <fetcher.Form method="post" style={{ display: "inline" }}>
        <input type="hidden" name="productId" value={item.id} />
        <input type="hidden" name="intent" value="updateQuantity" />
        <input
          type="number"
          name="quantity"
          defaultValue={item.quantity}
          onChange={(e) => fetcher.submit(e.currentTarget.form)}
          min="1"
        />
      </fetcher.Form>
      <fetcher.Form method="post" style={{ display: "inline" }}>
        <input type="hidden" name="productId" value={item.id} />
        <input type="hidden" name="intent" value="remove" />
        <button type="submit">Remove</button>
      </fetcher.Form>
    </div>
  );
}

Best Practices

Mark Pending Items

// Good: Visual indicator for pending items
<li className={todo.pending ? "opacity-50" : ""}>
  {todo.text}
</li>

// Bad: No indication item is pending
<li>{todo.text}</li>

Provide Immediate Feedback

// Good: Button state changes immediately
<button disabled={fetcher.state === "submitting"}>
  {fetcher.state === "submitting" ? "Saving..." : "Save"}
</button>

// Bad: No feedback until server responds
<button>Save</button>

Handle Errors Gracefully

// Good: Show error and allow retry
if (fetcher.data?.error) {
  return (
    <div>
      <p>Error: {fetcher.data.error}</p>
      <button onClick={() => fetcher.reset()}>Try Again</button>
    </div>
  );
}

Build docs developers (and LLMs) love