Back to Notes
UX2 phút đọcOctober 8, 2025

Checkout Flow & Form UX — Tại sao user bỏ giỏ hàng ở bước cuối

70% người dùng bỏ giỏ hàng ở bước checkout. Phần lớn không phải vì giá, mà vì UX form tệ. Bài này phân tích từng điểm đau trong checkout flow và cách xử lý đúng.

Checkout Flow & Form UX — Tại sao user bỏ giỏ hàng ở bước cuối

Theo Baymard Institute, tỷ lệ bỏ giỏ hàng trung bình là 70.19%. Trong đó, phần lớn là do checkout quá phức tạp, yêu cầu tạo tài khoản, hoặc form UX tệ. Đây là những vấn đề kỹ thuật có thể fix được.

Vấn đề 1: Multi-step Form — Giữ State khi Navigate

User điền thông tin ở bước 2, click Back về bước 1 rồi Forward lại — form trống. Đây là lỗi UX nghiêm trọng.

tsx
// hooks/useCheckoutForm.ts
// Dùng URL params để persist state qua navigation
import { useSearchParams, useRouter } from "next/navigation";
 
const STEPS = ["shipping", "payment", "review"] as const;
type CheckoutStep = (typeof STEPS)[number];
 
export function useCheckoutStepper() {
  const router = useRouter();
  const searchParams = useSearchParams();
  const currentStep = (searchParams.get("step") as CheckoutStep) ?? "shipping";
 
  // State lưu trong sessionStorage để survive browser refresh
  const [formData, setFormData] = useSessionStorageState<CheckoutFormData>(
    "checkout_data",
    defaultFormData
  );
 
  const goToStep = (step: CheckoutStep) => {
    const params = new URLSearchParams(searchParams.toString());
    params.set("step", step);
    router.push(`/checkout?${params.toString()}`);
  };
 
  const updateStep = (step: CheckoutStep, data: Partial<CheckoutFormData>) => {
    setFormData((prev) => ({ ...prev, ...data }));
    const nextStep = STEPS[STEPS.indexOf(step) + 1];
    if (nextStep) goToStep(nextStep);
  };
 
  return { currentStep, formData, updateStep, goToStep };
}

Vấn đề 2: Validation UX — Inline vs Submit-time

Rule đơn giản:

  • Validate inline (onChange) chỉ sau khi field đã bị touched (blur ít nhất 1 lần)
  • Không validate khi user đang gõ email — chờ họ blur trước
  • Submit-time: validate toàn bộ và focus vào field lỗi đầu tiên
tsx
function EmailField() {
  const [value, setValue] = useState("");
  const [touched, setTouched] = useState(false);
  const error = touched && !isValidEmail(value) ? "Email không hợp lệ" : null;
 
  return (
    <div>
      <input
        type="email"
        value={value}
        inputMode="email"
        autoComplete="email"
        onChange={(e) => setValue(e.target.value)}
        onBlur={() => setTouched(true)} // Chỉ bắt đầu validate sau khi blur
        aria-describedby={error ? "email-error" : undefined}
        aria-invalid={!!error}
      />
      {error && (
        <p id="email-error" role="alert" className="text-red-500 text-sm mt-1">
          {error}
        </p>
      )}
    </div>
  );
}

Vấn đề 3: Payment Redirect Recovery

User bị redirect sang trang thanh toán ngân hàng, browser crash giữa chừng. Khi họ quay lại, đơn hàng ở trạng thái nào?

ts
// Trước khi redirect sang payment gateway
export async function initiatePayment(orderId: string) {
  // Lưu payment intent vào sessionStorage trước khi rời trang
  sessionStorage.setItem(
    "pending_payment",
    JSON.stringify({
      orderId,
      initiatedAt: Date.now(),
      returnUrl: window.location.href,
    })
  );
 
  const { redirectUrl } = await createPaymentIntent(orderId);
  window.location.href = redirectUrl;
}
 
// Khi user quay lại (ở /checkout/result hoặc trang chủ)
export function checkPendingPayment() {
  const pending = sessionStorage.getItem("pending_payment");
  if (!pending) return null;
 
  const { orderId, initiatedAt } = JSON.parse(pending);
  const isExpired = Date.now() - initiatedAt > 30 * 60 * 1000; // 30 phút
 
  if (isExpired) {
    sessionStorage.removeItem("pending_payment");
    return null;
  }
 
  return { orderId }; // Hiển thị banner "Bạn có đơn hàng chưa hoàn thành"
}

Vấn đề 4: Address Autocomplete với Debounce

tsx
function AddressAutocomplete({ onSelect }: { onSelect: (addr: Address) => void }) {
  const [query, setQuery] = useState("");
  const [suggestions, setSuggestions] = useState<Address[]>([]);
 
  // Debounce 350ms — đủ nhanh cho UX, đủ chậm để không spam API
  const fetchSuggestions = useMemo(
    () =>
      debounce(async (q: string) => {
        if (q.length < 3) return setSuggestions([]);
        const results = await searchAddress(q);
        setSuggestions(results);
      }, 350),
    []
  );
 
  useEffect(() => {
    fetchSuggestions(query);
  }, [query, fetchSuggestions]);
 
  return (
    <div role="combobox" aria-expanded={suggestions.length > 0}>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        autoComplete="off" // Tắt browser autocomplete khi có custom suggestion
      />
      {suggestions.length > 0 && (
        <ul role="listbox">
          {suggestions.map((addr) => (
            <li key={addr.id} role="option" onClick={() => onSelect(addr)}>
              {addr.fullAddress}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Vấn đề 5: Sticky Order Summary trên Mobile

tsx
// Sticky summary không bị virtual keyboard che
function MobileOrderSummary() {
  const [keyboardHeight, setKeyboardHeight] = useState(0);
 
  useEffect(() => {
    const updateKeyboardHeight = () => {
      // visualViewport API cho kết quả chính xác hơn window.innerHeight
      const vv = window.visualViewport;
      if (vv) {
        const kbHeight = window.innerHeight - vv.height;
        setKeyboardHeight(Math.max(0, kbHeight));
      }
    };
 
    window.visualViewport?.addEventListener("resize", updateKeyboardHeight);
    return () => window.visualViewport?.removeEventListener("resize", updateKeyboardHeight);
  }, []);
 
  return (
    <div
      className="fixed bottom-0 left-0 right-0 bg-white border-t p-4"
      style={{ bottom: keyboardHeight }}
    >
      <OrderSummaryContent />
    </div>
  );
}

Key Takeaways

  • Multi-step form: persist state trong sessionStorage + URL params, không dùng React state thuần
  • Validation: chỉ inline sau blur, submit-time focus field lỗi đầu tiên
  • Payment redirect: lưu pending payment trước khi redirect, recover khi quay lại
  • Autocomplete: debounce 350ms, tắt browser native autocomplete khi có custom UI
  • Mobile sticky CTA: dùng visualViewport API để tính chiều cao virtual keyboard

If you found this helpful, leave a like!

Related Posts

Comments available after DB is connected.