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
visualViewportAPI để tính chiều cao virtual keyboard
If you found this helpful, leave a like!
Related Posts
Comments available after DB is connected.