Repository: clerk/javascript
Pull request #5952: chore(clerk-js): Refactor CheckoutPage into composable stages
Author: login: panteliselef
Labels: name: clerk-js
Created: 2025-05-16T23:24:12Z | Merged: 2025-05-28T01:19:42Z

## Description

Refactoring CheckoutPage to make it easy to identify all states of the component.

<!-- 
  Thanks for contributing to Clerk. Make sure to read the contributing guide at https://github.com/clerk/javascript/blob/main/docs/CONTRIBUTING.md before opening a PR!

  **Please create a feature request before starting work on any significant change.**

  Write a brief description of the changes introduced in this PR.
  Include screenshots/videos if they help convey the change.

  Also explain how one can test the change.
-->

<!-- Fixes #(issue number) -->

## Checklist

- [ ] `pnpm test` runs as expected.
- [ ] `pnpm build` runs as expected.
- [ ] (If applicable) [JSDoc comments](https://jsdoc.app/about-getting-started.html) have been added or updated for any package exports
- [ ] (If applicable) [Documentation](https://github.com/clerk/clerk-docs) has been updated

## Type of change

- [ ] 🐛 Bug fix
- [ ] 🌟 New feature
- [ ] 🔨 Breaking change
- [ ] 📖 Refactoring / dependency upgrade / documentation
- [ ] other:



Diff:
diff --git a/.changeset/heavy-banks-talk.md b/.changeset/heavy-banks-talk.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/heavy-banks-talk.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json
index 24ae8062b1a..b7b6431ac73 100644
--- a/packages/clerk-js/bundlewatch.config.json
+++ b/packages/clerk-js/bundlewatch.config.json
@@ -21,7 +21,7 @@
     { "path": "./dist/waitlist*.js", "maxSize": "1.3KB" },
     { "path": "./dist/keylessPrompt*.js", "maxSize": "6.5KB" },
     { "path": "./dist/pricingTable*.js", "maxSize": "4.02KB" },
-    { "path": "./dist/checkout*.js", "maxSize": "6.65KB" },
+    { "path": "./dist/checkout*.js", "maxSize": "6.83KB" },
     { "path": "./dist/paymentSources*.js", "maxSize": "9.06KB" },
     { "path": "./dist/up-billing-page*.js", "maxSize": "3.0KB" },
     { "path": "./dist/op-billing-page*.js", "maxSize": "3.0KB" },
diff --git a/packages/clerk-js/src/ui/components/Checkout/Checkout.tsx b/packages/clerk-js/src/ui/components/Checkout/Checkout.tsx
index d5bb39a078e..86af579f953 100644
--- a/packages/clerk-js/src/ui/components/Checkout/Checkout.tsx
+++ b/packages/clerk-js/src/ui/components/Checkout/Checkout.tsx
@@ -1,9 +1,12 @@
 import type { __internal_CheckoutProps } from '@clerk/types';
 
 import { CheckoutContext, SubscriberTypeContext } from '../../contexts';
-import { Flow, localizationKeys } from '../../customizables';
+import { Flow, localizationKeys, Spinner } from '../../customizables';
 import { Drawer } from '../../elements';
-import { CheckoutPage } from './CheckoutPage';
+import { CheckoutComplete } from './CheckoutComplete';
+import { CheckoutForm } from './CheckoutForm';
+import * as CheckoutPage from './CheckoutPage';
+import { AddEmailForm, GenericError, InvalidPlanError } from './parts';
 
 export const Checkout = (props: __internal_CheckoutProps) => {
   return (
@@ -18,7 +21,35 @@ export const Checkout = (props: __internal_CheckoutProps) => {
           >
             <Drawer.Content>
               <Drawer.Header title={localizationKeys('commerce.checkout.title')} />
-              <CheckoutPage {...props} />
+              <CheckoutPage.Root>
+                <CheckoutPage.Stage name='pending'>
+                  <Spinner
+                    sx={{
+                      margin: 'auto',
+                    }}
+                  />
+                </CheckoutPage.Stage>
+
+                <CheckoutPage.Stage name='completed'>
+                  <CheckoutComplete />
+                </CheckoutPage.Stage>
+
+                <CheckoutPage.Stage name='error'>
+                  <GenericError />
+                </CheckoutPage.Stage>
+
+                <CheckoutPage.Stage name='invalid_plan_change'>
+                  <InvalidPlanError />
+                </CheckoutPage.Stage>
+
+                <CheckoutPage.Stage name='missing_payer_email'>
+                  <AddEmailForm />
+                </CheckoutPage.Stage>
+
+                <CheckoutPage.Stage name='ready'>
+                  <CheckoutForm />
+                </CheckoutPage.Stage>
+              </CheckoutPage.Root>
             </Drawer.Content>
           </CheckoutContext.Provider>
         </SubscriberTypeContext.Provider>
diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx
index b009a1d6159..8b9cd08843b 100644
--- a/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx
+++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx
@@ -1,28 +1,29 @@
-import type { CommerceCheckoutResource } from '@clerk/types';
 import { useEffect, useRef, useState } from 'react';
 
 import { useCheckoutContext } from '../../contexts';
-import { Box, Button, descriptors, Heading, localizationKeys, Span, Text } from '../../customizables';
+import { Box, Button, descriptors, Heading, localizationKeys, Span, Text, useAppearance } from '../../customizables';
 import { Drawer, LineItems, useDrawerContext } from '../../elements';
 import { transitionDurationValues, transitionTiming } from '../../foundations/transitions';
+import { usePrefersReducedMotion } from '../../hooks';
 import { useRouter } from '../../router';
 import { formatDate } from '../../utils';
+import { useCheckoutContextRoot } from './CheckoutPage';
 
 const capitalize = (name: string) => name[0].toUpperCase() + name.slice(1);
 const lerp = (start: number, end: number, amt: number) => start + (end - start) * amt;
 
-export const CheckoutComplete = ({
-  checkout,
-  isMotionSafe,
-}: {
-  checkout: CommerceCheckoutResource;
-  isMotionSafe: boolean;
-}) => {
+export const CheckoutComplete = () => {
   const router = useRouter();
   const { setIsOpen } = useDrawerContext();
   const { newSubscriptionRedirectUrl } = useCheckoutContext();
+  const { checkout } = useCheckoutContextRoot();
   const [mousePosition, setMousePosition] = useState({ x: 256, y: 256 });
   const [currentPosition, setCurrentPosition] = useState({ x: 256, y: 256 });
+
+  const prefersReducedMotion = usePrefersReducedMotion();
+  const { animations: layoutAnimations } = useAppearance().parsedLayout;
+  const isMotionSafe = !prefersReducedMotion && layoutAnimations === true;
+
   const animationRef = useRef<number | null>(null);
   const checkoutSuccessRootRef = useRef<HTMLSpanElement>(null);
   const canHover =
@@ -74,6 +75,10 @@ export const CheckoutComplete = ({
     }
   };
 
+  if (!checkout) {
+    return null;
+  }
+
   return (
     <>
       <Drawer.Body>
@@ -232,14 +237,11 @@ export const CheckoutComplete = ({
                 pathLength='1'
                 style={{
                   strokeDashoffset: '1',
-                  animationName: 'check',
-                  animationDuration: `${transitionDurationValues.drawer}ms`,
-                  animationTimingFunction: transitionTiming.bezier,
-                  animationFillMode: 'forwards',
-                  animationDelay: `${transitionDurationValues.slow}ms`,
+                  animation: isMotionSafe
+                    ? `check ${transitionDurationValues.drawer}ms ${transitionTiming.bezier} forwards ${transitionDurationValues.slow}ms`
+                    : 'none',
                   ...(!isMotionSafe && {
                     strokeDashoffset: '0',
-                    animation: 'none',
                   }),
                 }}
               />
diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx
index 8b2772ce395..a2840a3109c 100644
--- a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx
+++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx
@@ -17,23 +17,31 @@ import { ChevronUpDown } from '../../icons';
 import { animations } from '../../styledSystem';
 import { handleError } from '../../utils';
 import { AddPaymentSource, PaymentSourceRow } from '../PaymentSources';
+import { useCheckoutContextRoot } from './CheckoutPage';
 
 type PaymentMethodSource = 'existing' | 'new';
 
 const capitalize = (name: string) => name[0].toUpperCase() + name.slice(1);
 
-export const CheckoutForm = ({
-  checkout,
-  onCheckoutComplete,
-}: {
-  checkout: CommerceCheckoutResource;
-  onCheckoutComplete: (checkout: CommerceCheckoutResource) => void;
-}) => {
+export const CheckoutForm = () => {
+  const ctx = useCheckoutContextRoot();
+  const { onSubscriptionComplete } = useCheckoutContext();
+  const { checkout, updateCheckout } = ctx;
+
+  if (!checkout) {
+    return null;
+  }
+
   const { plan, planPeriod, totals, isImmediatePlanChange } = checkout;
   const showCredits = !!totals.credit?.amount && totals.credit.amount > 0;
   const showPastDue = !!totals.pastDue?.amount && totals.pastDue.amount > 0;
   const showDowngradeInfo = !isImmediatePlanChange;
 
+  const onCheckoutComplete = (newCheckout: CommerceCheckoutResource) => {
+    void updateCheckout(newCheckout);
+    onSubscriptionComplete?.();
+  };
+
   return (
     <Drawer.Body>
       <Box
diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx
index cb6b9e463b7..787b2f90437 100644
--- a/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx
+++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx
@@ -1,157 +1,135 @@
-import type { __internal_CheckoutProps, ClerkAPIError, CommerceCheckoutResource } from '@clerk/types';
-
-import { usePlans } from '../../contexts';
-import {
-  Box,
-  descriptors,
-  Flex,
-  localizationKeys,
-  Spinner,
-  useAppearance,
-  useLocalizations,
-} from '../../customizables';
-import { Alert, Drawer, LineItems, useDrawerContext } from '../../elements';
-import { usePrefersReducedMotion } from '../../hooks';
-// TODO(@COMMERCE): Is this causing bundle size  issues ?
-import { EmailForm } from '../UserProfile/EmailForm';
-import { CheckoutComplete } from './CheckoutComplete';
-import { CheckoutForm } from './CheckoutForm';
-import { useCheckout } from './useCheckout';
-
-export const CheckoutPage = (props: __internal_CheckoutProps) => {
-  const { translateError } = useLocalizations();
-  const { t } = useLocalizations();
-  const { planId, planPeriod, subscriberType, onSubscriptionComplete } = props;
-  const { setIsOpen } = useDrawerContext();
-  const prefersReducedMotion = usePrefersReducedMotion();
-  const { animations: layoutAnimations } = useAppearance().parsedLayout;
-  const isMotionSafe = !prefersReducedMotion && layoutAnimations === true;
-  const { data: plans, isLoading: plansLoading } = usePlans();
+import { useClerk, useOrganization, useUser } from '@clerk/shared/react';
+import type { ClerkAPIError, CommerceCheckoutResource, CommercePlanResource } from '@clerk/types';
+import { createContext, useContext, useEffect, useMemo } from 'react';
+import useSWR from 'swr';
+import useSWRMutation from 'swr/mutation';
 
-  const { checkout, isLoading, updateCheckout, errors, startCheckout } = useCheckout({
-    planId,
-    planPeriod,
-    subscriberType,
-  });
+import { useCheckoutContext, usePlans } from '../../contexts';
 
-  const isMissingPayerEmail = !!errors?.some((e: ClerkAPIError) => e.code === 'missing_payer_email');
+type CheckoutStatus = 'pending' | 'ready' | 'completed' | 'missing_payer_email' | 'invalid_plan_change' | 'error';
 
-  const plan = plans?.find(p => p.id === planId);
+const CheckoutContextRoot = createContext<{
+  checkout: CommerceCheckoutResource | undefined;
+  isLoading: boolean;
+  updateCheckout: (checkout: CommerceCheckoutResource) => void;
+  errors: ClerkAPIError[];
+  startCheckout: () => void;
+  plan: CommercePlanResource | undefined;
+  status: CheckoutStatus;
+} | null>(null);
+
+export const useCheckoutContextRoot = () => {
+  const ctx = useContext(CheckoutContextRoot);
+  if (!ctx) {
+    throw new Error('CheckoutContextRoot not found');
+  }
+  return ctx;
+};
+
+const useCheckoutCreator = () => {
+  const { planId, planPeriod, subscriberType = 'user' } = useCheckoutContext();
+  const clerk = useClerk();
+  const { organization } = useOrganization();
+
+  const { user } = useUser();
 
-  const onCheckoutComplete = (newCheckout: CommerceCheckoutResource) => {
-    void updateCheckout(newCheckout);
-    onSubscriptionComplete?.();
+  const cacheKey = {
+    key: `commerce-checkout`,
+    userId: user?.id,
+    arguments: {
+      ...(subscriberType === 'org' ? { orgId: organization?.id } : {}),
+      planId,
+      planPeriod,
+    },
   };
 
-  if (isLoading || plansLoading) {
-    return (
-      <Spinner
-        sx={{
-          margin: 'auto',
-        }}
-      />
-    );
-  }
+  // Manually handle the cache
+  const { data, mutate } = useSWR<CommerceCheckoutResource | undefined>(cacheKey);
 
-  if (checkout) {
-    if (checkout?.status === 'completed') {
-      return (
-        <CheckoutComplete
-          checkout={checkout}
-          isMotionSafe={isMotionSafe}
-        />
-      );
-    }
-
-    return (
-      <CheckoutForm
-        checkout={checkout}
-        onCheckoutComplete={onCheckoutComplete}
-      />
-    );
-  }
+  // Use `useSWRMutation` to avoid revalidations on stale-data/focus etc.
+  const {
+    trigger: startCheckout,
+    isMutating,
+    error,
+  } = useSWRMutation(
+    cacheKey,
+    key =>
+      clerk.billing?.startCheckout(
+        // @ts-expect-error things are typed as optional
+        key.arguments,
+      ),
+    {
+      // Never throw on error, we want to handle it during rendering
+      throwOnError: false,
+      onSuccess: data => {
+        mutate(data, false);
+      },
+    },
+  );
 
-  if (isMissingPayerEmail) {
-    return (
-      <Drawer.Body>
-        <Box
-          sx={t => ({
-            padding: t.space.$4,
-          })}
-        >
-          <EmailForm
-            title={localizationKeys('commerce.checkout.emailForm.title')}
-            subtitle={localizationKeys('commerce.checkout.emailForm.subtitle')}
-            onSuccess={startCheckout}
-            onReset={() => setIsOpen(false)}
-            disableAutoFocus
-          />
-        </Box>
-      </Drawer.Body>
-    );
-  }
+  useEffect(() => {
+    void startCheckout();
+    return () => {
+      // Clear the cache on unmount
+      mutate(undefined, false);
+    };
+  }, []);
 
-  const error = errors?.[0];
-  if (error?.code === 'invalid_plan_change' && plan) {
-    return (
-      <Drawer.Body>
-        <Flex
-          gap={4}
-          direction='col'
-        >
-          <Box
-            elementDescriptor={descriptors.checkoutFormLineItemsRoot}
-            sx={t => ({
-              padding: t.space.$4,
-              borderBottomWidth: t.borderWidths.$normal,
-              borderBottomStyle: t.borderStyles.$solid,
-              borderBottomColor: t.colors.$neutralAlpha100,
-            })}
-          >
-            <LineItems.Root>
-              <LineItems.Group>
-                <LineItems.Title
-                  title={plan.name}
-                  description={planPeriod === 'annual' ? localizationKeys('commerce.billedAnnually') : undefined}
-                />
-                <LineItems.Description
-                  prefix={planPeriod === 'annual' ? 'x12' : undefined}
-                  text={`${plan.currencySymbol}${planPeriod === 'month' ? plan.amountFormatted : plan.annualMonthlyAmountFormatted}`}
-                  suffix={localizationKeys('commerce.checkout.perMonth')}
-                />
-              </LineItems.Group>
-            </LineItems.Root>
-          </Box>
-          <Box sx={t => ({ padding: t.space.$4 })}>
-            <Alert
-              variant='info'
-              colorScheme='info'
-              title={localizationKeys('commerce.cannotSubscribeMonthly')}
-            />
-          </Box>
-        </Flex>
-      </Drawer.Body>
-    );
-  }
+  return {
+    checkout: data,
+    startCheckout,
+    updateCheckout: (checkout: CommerceCheckoutResource) => mutate(checkout, false),
+    isMutating,
+    errors: error?.errors,
+  };
+};
+
+const Root = ({ children }: { children: React.ReactNode }) => {
+  const { planId } = useCheckoutContext();
+  const { data: plans, isLoading: plansLoading } = usePlans();
+  const { checkout, isMutating, updateCheckout, errors, startCheckout } = useCheckoutCreator();
+
+  const plan = plans?.find(p => p.id === planId);
+
+  const isLoading = isMutating || plansLoading;
+
+  const status = useMemo(() => {
+    if (isLoading) return 'pending';
+    const completedCode = 'completed';
+    if (checkout?.status === completedCode) return completedCode;
+    if (checkout) return 'ready';
+
+    const missingCode = 'missing_payer_email';
+    const isMissingPayerEmail = !!errors?.some((e: ClerkAPIError) => e.code === missingCode);
+    if (isMissingPayerEmail) return missingCode;
+    const invalidChangeCode = 'invalid_plan_change';
+    if (errors?.[0]?.code === invalidChangeCode && plan) return invalidChangeCode;
+    return 'error';
+  }, [isLoading, errors, checkout, plan?.id, checkout?.status]);
 
   return (
-    <Drawer.Body>
-      <Flex
-        align={'center'}
-        justify={'center'}
-        sx={t => ({
-          height: '100%',
-          padding: t.space.$4,
-          fontSize: t.fontSizes.$md,
-        })}
-      >
-        <Alert
-          variant='danger'
-          colorScheme='danger'
-        >
-          {errors ? translateError(errors[0]) : t(localizationKeys('unstable__errors.form_param_value_invalid'))}
-        </Alert>
-      </Flex>
-    </Drawer.Body>
+    <CheckoutContextRoot.Provider
+      value={{
+        checkout,
+        isLoading,
+        updateCheckout,
+        errors,
+        startCheckout,
+        plan,
+        status,
+      }}
+    >
+      {children}
+    </CheckoutContextRoot.Provider>
   );
 };
+
+const Stage = ({ children, name }: { children: React.ReactNode; name: CheckoutStatus }) => {
+  const ctx = useCheckoutContextRoot();
+  if (ctx.status !== name) {
+    return null;
+  }
+  return children;
+};
+
+export { Root, Stage };
diff --git a/packages/clerk-js/src/ui/components/Checkout/parts.tsx b/packages/clerk-js/src/ui/components/Checkout/parts.tsx
new file mode 100644
index 00000000000..8f8b193bbb5
--- /dev/null
+++ b/packages/clerk-js/src/ui/components/Checkout/parts.tsx
@@ -0,0 +1,104 @@
+import { useCheckoutContext } from '../../contexts';
+import { Box, descriptors, Flex, localizationKeys, useLocalizations } from '../../customizables';
+import { Alert, Drawer, LineItems, useDrawerContext } from '../../elements';
+// TODO(@COMMERCE): Is this causing bundle size  issues ?
+import { EmailForm } from '../UserProfile/EmailForm';
+import { useCheckoutContextRoot } from './CheckoutPage';
+
+export const GenericError = () => {
+  const { errors } = useCheckoutContextRoot();
+  const { translateError } = useLocalizations();
+  const { t } = useLocalizations();
+  return (
+    <Drawer.Body>
+      <Flex
+        align={'center'}
+        justify={'center'}
+        sx={t => ({
+          height: '100%',
+          padding: t.space.$4,
+          fontSize: t.fontSizes.$md,
+        })}
+      >
+        <Alert
+          variant='danger'
+          colorScheme='danger'
+        >
+          {errors ? translateError(errors[0]) : t(localizationKeys('unstable__errors.form_param_value_invalid'))}
+        </Alert>
+      </Flex>
+    </Drawer.Body>
+  );
+};
+
+export const InvalidPlanError = () => {
+  const { plan } = useCheckoutContextRoot();
+  const { planPeriod } = useCheckoutContext();
+
+  if (!plan) {
+    return null;
+  }
+
+  return (
+    <Drawer.Body>
+      <Flex
+        gap={4}
+        direction='col'
+      >
+        <Box
+          elementDescriptor={descriptors.checkoutFormLineItemsRoot}
+          sx={t => ({
+            padding: t.space.$4,
+            borderBottomWidth: t.borderWidths.$normal,
+            borderBottomStyle: t.borderStyles.$solid,
+            borderBottomColor: t.colors.$neutralAlpha100,
+          })}
+        >
+          <LineItems.Root>
+            <LineItems.Group>
+              <LineItems.Title
+                title={plan.name}
+                description={planPeriod === 'annual' ? localizationKeys('commerce.billedAnnually') : undefined}
+              />
+              <LineItems.Description
+                prefix={planPeriod === 'annual' ? 'x12' : undefined}
+                text={`${plan.currencySymbol}${planPeriod === 'month' ? plan.amountFormatted : plan.annualMonthlyAmountFormatted}`}
+                suffix={localizationKeys('commerce.checkout.perMonth')}
+              />
+            </LineItems.Group>
+          </LineItems.Root>
+        </Box>
+        <Box sx={t => ({ padding: t.space.$4 })}>
+          <Alert
+            variant='info'
+            colorScheme='info'
+            title={localizationKeys('commerce.cannotSubscribeMonthly')}
+          />
+        </Box>
+      </Flex>
+    </Drawer.Body>
+  );
+};
+
+export const AddEmailForm = () => {
+  const { startCheckout } = useCheckoutContextRoot();
+  const { setIsOpen } = useDrawerContext();
+  return (
+    <Drawer.Body>
+      <Box
+        sx={t => ({
+          padding: t.space.$4,
+        })}
+      >
+        {/* TODO(@COMMERCE): How does ths operate for orgs ? */}
+        <EmailForm
+          title={localizationKeys('commerce.checkout.emailForm.title')}
+          subtitle={localizationKeys('commerce.checkout.emailForm.subtitle')}
+          onSuccess={startCheckout}
+          onReset={() => setIsOpen(false)}
+          disableAutoFocus
+        />
+      </Box>
+    </Drawer.Body>
+  );
+};
diff --git a/packages/clerk-js/src/ui/components/Checkout/useCheckout.ts b/packages/clerk-js/src/ui/components/Checkout/useCheckout.ts
deleted file mode 100644
index b09d964db3e..00000000000
--- a/packages/clerk-js/src/ui/components/Checkout/useCheckout.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { useClerk, useOrganization, useUser } from '@clerk/shared/react';
-import type { __internal_CheckoutProps, CommerceCheckoutResource } from '@clerk/types';
-import { useEffect } from 'react';
-import useSWR from 'swr';
-import useSWRMutation from 'swr/mutation';
-
-export const useCheckout = (props: __internal_CheckoutProps) => {
-  const { planId, planPeriod, subscriberType = 'user' } = props;
-  const clerk = useClerk();
-  const { organization } = useOrganization();
-
-  const { user } = useUser();
-
-  const cacheKey = {
-    key: `commerce-checkout`,
-    userId: user?.id,
-    arguments: {
-      ...(subscriberType === 'org' ? { orgId: organization?.id } : {}),
-      planId,
-      planPeriod,
-    },
-  };
-
-  // Manually handle the cache
-  const { data, mutate } = useSWR(cacheKey);
-
-  // Use `useSWRMutation` to avoid revalidations on stale-data/focus etc.
-  const {
-    trigger: startCheckout,
-    isMutating,
-    error,
-  } = useSWRMutation(
-    cacheKey,
-    key =>
-      clerk.billing?.startCheckout(
-        // @ts-expect-error things are typed as optional
-        key.arguments,
-      ),
-    {
-      // Never throw on error, we want to handle it during rendering
-      throwOnError: false,
-      onSuccess: data => {
-        mutate(data, false);
-      },
-    },
-  );
-
-  useEffect(() => {
-    void startCheckout();
-    return () => {
-      // Clear the cache on unmount
-      mutate(undefined, false);
-    };
-  }, []);
-
-  return {
-    checkout: data,
-    startCheckout,
-    updateCheckout: (checkout: CommerceCheckoutResource) => mutate(checkout, false),
-    isLoading: isMutating,
-    errors: error?.errors,
-  };
-};


Reviews:
user: login: panteliselef
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T23:25:47Z
user: login: panteliselef
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T15:40:40Z
user: login: panteliselef
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T15:42:14Z
user: login: octoper
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-26T10:36:31Z

Review comments:
diff_hunk: @@ -119,14 +117,11 @@ export const CheckoutComplete = ({
                 pathLength='1'
                 style={{
                   strokeDashoffset: '1',
-                  animationName: 'check',
-                  animationDuration: `${transitionDurationValues.drawer}ms`,
-                  animationTimingFunction: transitionTiming.bezier,
-                  animationFillMode: 'forwards',
-                  animationDelay: `${transitionDurationValues.slow}ms`,
+                  animation: isMotionSafe
+                    ? `check ${transitionDurationValues.drawer}ms ${transitionTiming.bezier} forwards ${transitionDurationValues.slow}ms`
+                    : 'none',
path: packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx
user: login: panteliselef
body: After the changes of this PR the previous implementation started to break and the I was getting this error on the console

> "Removing a style property during rerender (animation) when a conflicting property is set (animationDelay) can lead to styling bugs. To avoid this, don't mix shorthand and non-shorthand properties for the same value; instead, replace the shorthand with separate values."
created_at: 2025-05-16T23:25:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,157 +1,136 @@
-import type { __internal_CheckoutProps, ClerkAPIError, CommerceCheckoutResource } from '@clerk/types';
-
-import { usePlans } from '../../contexts';
-import {
-  Box,
-  descriptors,
-  Flex,
-  localizationKeys,
-  Spinner,
-  useAppearance,
-  useLocalizations,
-} from '../../customizables';
-import { Alert, Drawer, LineItems, useDrawerContext } from '../../elements';
-import { usePrefersReducedMotion } from '../../hooks';
-// TODO(@COMMERCE): Is this causing bundle size  issues ?
-import { EmailForm } from '../UserProfile/EmailForm';
-import { CheckoutComplete } from './CheckoutComplete';
-import { CheckoutForm } from './CheckoutForm';
-import { useCheckout } from './useCheckout';
-
-export const CheckoutPage = (props: __internal_CheckoutProps) => {
-  const { translateError } = useLocalizations();
-  const { t } = useLocalizations();
-  const { planId, planPeriod, subscriberType, onSubscriptionComplete } = props;
-  const { setIsOpen } = useDrawerContext();
-  const prefersReducedMotion = usePrefersReducedMotion();
-  const { animations: layoutAnimations } = useAppearance().parsedLayout;
-  const isMotionSafe = !prefersReducedMotion && layoutAnimations === true;
-  const { data: plans, isLoading: plansLoading } = usePlans();
+import { useClerk, useOrganization } from '@clerk/shared/react';
+import { useUser } from '@clerk/shared/react/index';
+import type { ClerkAPIError, CommerceCheckoutResource, CommercePlanResource } from '@clerk/types';
+import { createContext, useContext, useEffect, useMemo } from 'react';
+import useSWR from 'swr';
+import useSWRMutation from 'swr/mutation';
 
-  const { checkout, isLoading, updateCheckout, errors, startCheckout } = useCheckout({
-    planId,
-    planPeriod,
-    subscriberType,
-  });
+import { useCheckoutContext, usePlans } from '../../contexts';
 
-  const isMissingPayerEmail = !!errors?.some((e: ClerkAPIError) => e.code === 'missing_payer_email');
+type CheckoutStatus = 'pending' | 'ready' | 'completed' | 'missing_payer_email' | 'invalid_plan_change' | 'error';
 
-  const plan = plans?.find(p => p.id === planId);
+const CheckoutContextRoot = createContext<{
+  checkout: CommerceCheckoutResource | undefined;
+  isLoading: boolean;
+  updateCheckout: (checkout: CommerceCheckoutResource) => void;
+  errors: ClerkAPIError[];
+  startCheckout: () => void;
+  plan: CommercePlanResource | undefined;
+  status: CheckoutStatus;
+} | null>(null);
+
+export const useCheckoutContextRoot = () => {
+  const ctx = useContext(CheckoutContextRoot);
+  if (!ctx) {
+    throw new Error('CheckoutContextRoot not found');
+  }
+  return ctx;
+};
+
+const useCheckoutCreator = () => {
+  const { planId, planPeriod, subscriberType = 'user' } = useCheckoutContext();
+  const clerk = useClerk();
+  const { organization } = useOrganization();
+
+  const { user } = useUser();
 
-  const onCheckoutComplete = (newCheckout: CommerceCheckoutResource) => {
-    void updateCheckout(newCheckout);
-    onSubscriptionComplete?.();
+  const cacheKey = {
+    key: `commerce-checkout`,
+    userId: user?.id,
+    arguments: {
+      ...(subscriberType === 'org' ? { orgId: organization?.id } : {}),
+      planId,
+      planPeriod,
+    },
   };
 
-  if (isLoading || plansLoading) {
-    return (
-      <Spinner
-        sx={{
-          margin: 'auto',
-        }}
-      />
-    );
-  }
+  // Manually handle the cache
+  const { data, mutate } = useSWR<CommerceCheckoutResource | undefined>(cacheKey);
 
-  if (checkout) {
-    if (checkout?.status === 'completed') {
-      return (
-        <CheckoutComplete
-          checkout={checkout}
-          isMotionSafe={isMotionSafe}
-        />
-      );
-    }
-
-    return (
-      <CheckoutForm
-        checkout={checkout}
-        onCheckoutComplete={onCheckoutComplete}
-      />
-    );
-  }
+  // Use `useSWRMutation` to avoid revalidations on stale-data/focus etc.
+  const {
+    trigger: startCheckout,
+    isMutating,
+    error,
+  } = useSWRMutation(
+    cacheKey,
+    key =>
+      clerk.billing?.startCheckout(
+        // @ts-expect-error things are typed as optional
+        key.arguments,
+      ),
+    {
+      // Never throw on error, we want to handle it during rendering
+      throwOnError: false,
+      onSuccess: data => {
+        mutate(data, false);
+      },
+    },
+  );
 
-  if (isMissingPayerEmail) {
-    return (
-      <Drawer.Body>
-        <Box
-          sx={t => ({
-            padding: t.space.$4,
-          })}
-        >
-          <EmailForm
-            title={localizationKeys('commerce.checkout.emailForm.title')}
-            subtitle={localizationKeys('commerce.checkout.emailForm.subtitle')}
-            onSuccess={startCheckout}
-            onReset={() => setIsOpen(false)}
-            disableAutoFocus
-          />
-        </Box>
-      </Drawer.Body>
-    );
-  }
+  useEffect(() => {
+    void startCheckout();
+    return () => {
+      // Clear the cache on unmount
+      mutate(undefined, false);
+    };
+  }, []);
 
-  const error = errors?.[0];
-  if (error?.code === 'invalid_plan_change' && plan) {
-    return (
-      <Drawer.Body>
-        <Flex
-          gap={4}
-          direction='col'
-        >
-          <Box
-            elementDescriptor={descriptors.checkoutFormLineItemsRoot}
-            sx={t => ({
-              padding: t.space.$4,
-              borderBottomWidth: t.borderWidths.$normal,
-              borderBottomStyle: t.borderStyles.$solid,
-              borderBottomColor: t.colors.$neutralAlpha100,
-            })}
-          >
-            <LineItems.Root>
-              <LineItems.Group>
-                <LineItems.Title
-                  title={plan.name}
-                  description={planPeriod === 'annual' ? localizationKeys('commerce.billedAnnually') : undefined}
-                />
-                <LineItems.Description
-                  prefix={planPeriod === 'annual' ? 'x12' : undefined}
-                  text={`${plan.currencySymbol}${planPeriod === 'month' ? plan.amountFormatted : plan.annualMonthlyAmountFormatted}`}
-                  suffix={localizationKeys('commerce.checkout.perMonth')}
-                />
-              </LineItems.Group>
-            </LineItems.Root>
-          </Box>
-          <Box sx={t => ({ padding: t.space.$4 })}>
-            <Alert
-              variant='info'
-              colorScheme='info'
-              title={localizationKeys('commerce.cannotSubscribeMonthly')}
-            />
-          </Box>
-        </Flex>
-      </Drawer.Body>
-    );
-  }
+  return {
+    checkout: data,
+    startCheckout,
+    updateCheckout: (checkout: CommerceCheckoutResource) => mutate(checkout, false),
+    isMutating,
+    errors: error?.errors,
+  };
+};
+
+const Root = ({ children }: { children: React.ReactNode }) => {
+  const { planId } = useCheckoutContext();
+  const { data: plans, isLoading: plansLoading } = usePlans();
+  const { checkout, isMutating, updateCheckout, errors, startCheckout } = useCheckoutCreator();
+
+  const plan = plans?.find(p => p.id === planId);
+
+  const isLoading = isMutating || plansLoading;
+
+  const status = useMemo(() => {
+    if (isLoading) return 'pending';
+    const completedCode = 'completed';
+    if (checkout?.status === completedCode) return completedCode;
+    if (checkout) return 'ready';
+
+    const missingCode = 'missing_payer_email';
+    const isMissingPayerEmail = !!errors?.some((e: ClerkAPIError) => e.code === missingCode);
+    if (isMissingPayerEmail) return missingCode;
+    const invalidChangeCode = 'invalid_plan_change';
+    if (errors?.[0]?.code === invalidChangeCode && plan) return invalidChangeCode;
+    return 'error';
+  }, [isLoading, errors, checkout, plan?.id]);
 
   return (
-    <Drawer.Body>
-      <Flex
-        align={'center'}
-        justify={'center'}
-        sx={t => ({
-          height: '100%',
-          padding: t.space.$4,
-          fontSize: t.fontSizes.$md,
-        })}
-      >
-        <Alert
-          variant='danger'
-          colorScheme='danger'
-        >
-          {errors ? translateError(errors[0]) : t(localizationKeys('unstable__errors.form_param_value_invalid'))}
-        </Alert>
-      </Flex>
path: packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx
user: login: panteliselef
body: Moved to `parts.tsx`
created_at: 2025-05-22T15:40:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,63 +0,0 @@
-import { useClerk, useOrganization, useUser } from '@clerk/shared/react';
-import type { __internal_CheckoutProps, CommerceCheckoutResource } from '@clerk/types';
-import { useEffect } from 'react';
-import useSWR from 'swr';
-import useSWRMutation from 'swr/mutation';
-
-export const useCheckout = (props: __internal_CheckoutProps) => {
-  const { planId, planPeriod, subscriberType = 'user' } = props;
-  const clerk = useClerk();
-  const { organization } = useOrganization();
-
-  const { user } = useUser();
-
-  const cacheKey = {
-    key: `commerce-checkout`,
-    userId: user?.id,
-    arguments: {
path: packages/clerk-js/src/ui/components/Checkout/useCheckout.ts
user: login: panteliselef
body: This is not something that should be used/called multiple times, so it is moved inside `CheckoutContextRoot`
created_at: 2025-05-22T15:42:14Z
author_association: CONTRIBUTOR

PR comments:
user: login: changeset-bot[bot]
created_at: 2025-05-16T23:24:16Z
body: ###  🦋  Changeset detected

Latest commit: c8fb4640bd31fcc4a93eca16ab9dab8e8189e9eb

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 0 packages</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/clerk/javascript/new/elef/refactor-checkout-page?filename=.changeset/perfect-guests-share.md&value=---%0A%22%40clerk%2Fclerk-js%22%3A%20patch%0A---%0A%0Achore(clerk-js)%3A%20Refactor%20CheckoutPage%20into%20composable%20stages%0A)


author_association: NONE
user: login: vercel[bot]
created_at: 2025-05-16T23:24:17Z
body: [vc]: #CBaujQ2vVIjrIaMld2ci5El50aMme30/ax8N5aMcASQ=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJjbGVyay1qcy1zYW5kYm94IiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2NsZXJrLXByb2R1Y3Rpb24vY2xlcmstanMtc2FuZGJveC9FaWdxODljWGhTVEtvcG5aM1FYS3h2c0wybnNyIiwicHJldmlld1VybCI6ImNsZXJrLWpzLXNhbmRib3gtZ2l0LWVsZWYtcmVmYWN0b3ItY2hlY2tvdXQtcGFnZS5jbGVya3N0YWdlLmRldiIsIm5leHRDb21taXRTdGF0dXMiOiJERVBMT1lFRCIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJjbGVyay1qcy1zYW5kYm94LWdpdC1lbGVmLXJlZmFjdG9yLWNoZWNrb3V0LXBhZ2UuY2xlcmtzdGFnZS5kZXYifSwicm9vdERpcmVjdG9yeSI6InBhY2thZ2VzL2NsZXJrLWpzIn1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **clerk-js-sandbox** | ✅ Ready ([Inspect](https://vercel.com/clerk-production/clerk-js-sandbox/Eigq89cXhSTKopnZ3QXKxvsL2nsr)) | [Visit Preview](https://clerk-js-sandbox-git-elef-refactor-checkout-page.clerkstage.dev) | 💬 [**Add feedback**](https://vercel.live/open-feedback/clerk-js-sandbox-git-elef-refactor-checkout-page.clerkstage.dev?via=pr-comment-feedback-link) | May 28, 2025 1:13am |


author_association: NONE
user: login: pkg-pr-new[bot]
created_at: 2025-05-22T15:49:44Z
body: 
[Open in StackBlitz](https://pkg.pr.new/template/caa4f31f-29a0-45b1-b8c7-1af06fa7664d)


<details><summary><b>@clerk/agent-toolkit</b></summary><p>

```
npm i https://pkg.pr.new/@clerk/agent-toolkit@5952
```

</p></details>
      
    

<details><summary><b>@clerk/backend</b></summary><p>

```
npm i https://pkg.pr.new/@clerk/backend@5952
```

</p></details>
      
    

<details><summary><b>@clerk/chrome-extension</b></summary><p>

```
npm i https://pkg.pr.new/@clerk/chrome-extension@5952
```

</p></details>
      
    

<details><summary><b>@clerk/clerk-js</b></summary><p>

```
npm i https://pkg.pr.new/@clerk/clerk-js@5952
```

</p></details>
      
    

<details><summary><b>@clerk/astro</b></summary><p>

```
npm i https://pkg.pr.new/@clerk/astro@5952
```

</p></details>
      
    

<details><summary><b>@clerk/dev-cli</b></summary><p>

```
npm i https://pkg.pr.new/@clerk/dev-cli@5952
```

</p></details>
      
    

<details><summary><b>@clerk/elements</b></summary><p>

```
npm i https://pkg.pr.new/@clerk/elements@5952
```

</p></details>
      
    

<details><summary><b>@clerk/clerk-expo</b></summary><p>

```
npm i https://pkg.pr.new/@clerk/clerk-expo@5952
```

</p></details>
      
    

<details><summary><b>@clerk/expo-passkeys</b></summary><p>

```
npm i https://pkg.pr.new/@clerk/expo-passkeys@5952
```

</p></details>
      
    

<details><summary><b>@clerk/express</b></summary><p>

```
npm i https://pkg.pr.new/@clerk/express@5952
```

</p></details>
      
    

<details><summary><b>@clerk/fastify</b></summary><p>

```
npm i https://pkg.pr.new/@clerk/fastify@5952
```

</p></details>
      
    

<details><summary><b>@clerk/localizations</b></summary><p>

```
npm i https://pkg.pr.new/@clerk/localizations@5952
```

</p></details>
      
    

<details><summary><b>@clerk/nextjs</b></summary><p>

```
npm i https://pkg.pr.new/@clerk/nextjs@5952
```

</p></details>
      
    

<details><summary><b>@clerk/nuxt</b></summary><p>

```
npm i https://pkg.pr.new/@clerk/nuxt@5952
```

</p></details>
      
    

<details><summary><b>@clerk/clerk-react</b></summary><p>

```
npm i https://pkg.pr.new/@clerk/clerk-react@5952
```

</p></details>
      
    

<details><summary><b>@clerk/react-router</b></summary><p>

```
npm i https://pkg.pr.new/@clerk/react-router@5952
```

</p></details>
      
    

<details><summary><b>@clerk/remix</b></summary><p>

```
npm i https://pkg.pr.new/@clerk/remix@5952
```

</p></details>
      
    

<details><summary><b>@clerk/tanstack-react-start</b></summary><p>

```
npm i https://pkg.pr.new/@clerk/tanstack-react-start@5952
```

</p></details>
      
    

<details><summary><b>@clerk/testing</b></summary><p>

```
npm i https://pkg.pr.new/@clerk/testing@5952
```

</p></details>
      
    

<details><summary><b>@clerk/themes</b></summary><p>

```
npm i https://pkg.pr.new/@clerk/themes@5952
```

</p></details>
      
    

<details><summary><b>@clerk/shared</b></summary><p>

```
npm i https://pkg.pr.new/@clerk/shared@5952
```

</p></details>
      
    

<details><summary><b>@clerk/types</b></summary><p>

```
npm i https://pkg.pr.new/@clerk/types@5952
```

</p></details>
      
    

<details><summary><b>@clerk/upgrade</b></summary><p>

```
npm i https://pkg.pr.new/@clerk/upgrade@5952
```

</p></details>
      
    

<details><summary><b>@clerk/vue</b></summary><p>

```
npm i https://pkg.pr.new/@clerk/vue@5952
```

</p></details>
      
    

_commit: <a href="https://github.com/clerk/javascript/runs/43005424606"><code>c8fb464</code></a>_

author_association: NONE