diff --git a/changelog/dynamic-subscription-payment-methods-5508 b/changelog/dynamic-subscription-payment-methods-5508
new file mode 100644
index 00000000000..535726c4980
--- /dev/null
+++ b/changelog/dynamic-subscription-payment-methods-5508
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+When editing subscriptions, load payment methods whenever the customer is changed.
diff --git a/client/subscription-edit-page.js b/client/subscription-edit-page.js
deleted file mode 100644
index a230ab2d430..00000000000
--- a/client/subscription-edit-page.js
+++ /dev/null
@@ -1,57 +0,0 @@
-/* global wcpaySubscriptionEdit */
-
-const addOption = ( select, value, text ) => {
- const option = document.createElement( 'option' );
- option.value = value;
- option.text = text;
- select.appendChild( option );
- return option;
-};
-
-const addWCPayCards = ( {
- gateway,
- table,
- metaKey,
- tokens,
- defaultOptionText,
-} ) => {
- const paymentMethodInputId = `_payment_method_meta[${ gateway }][${ table }][${ metaKey }]`;
- const paymentMethodInput = document.getElementById( paymentMethodInputId );
- const validTokenId = tokens.some(
- ( token ) => token.tokenId.toString() === paymentMethodInput.value
- );
-
- // Abort if the input doesn't exist or is already a select element
- if ( ! paymentMethodInput || paymentMethodInput.tagName === 'SELECT' ) {
- return;
- }
-
- const paymentMethodSelect = document.createElement( 'select' );
- paymentMethodSelect.id = paymentMethodInputId;
- paymentMethodSelect.name = paymentMethodInputId;
-
- // Add placeholder option if no token matches the existing token ID.
- if ( ! validTokenId ) {
- const defaultOption = addOption(
- paymentMethodSelect,
- '',
- defaultOptionText
- );
- defaultOption.disabled = true;
- defaultOption.selected = true;
- }
-
- tokens.forEach( ( token ) => {
- addOption( paymentMethodSelect, token.tokenId, token.displayName );
- } );
-
- if ( validTokenId ) {
- paymentMethodSelect.value = paymentMethodInput.value;
- }
-
- const formField = paymentMethodInput.parentElement;
- formField.insertBefore( paymentMethodSelect, paymentMethodInput );
- paymentMethodInput.remove();
-};
-
-addWCPayCards( wcpaySubscriptionEdit );
diff --git a/client/subscription-edit-page/__tests__/index.test.tsx b/client/subscription-edit-page/__tests__/index.test.tsx
new file mode 100644
index 00000000000..f2e56e90193
--- /dev/null
+++ b/client/subscription-edit-page/__tests__/index.test.tsx
@@ -0,0 +1,545 @@
+/** @format */
+/**
+ * External dependencies
+ */
+import { render, screen, waitFor, act } from '@testing-library/react';
+import React from 'react';
+
+// Mock jQuery before importing the component
+const mockOn = jest.fn();
+const mockOff = jest.fn();
+const mockJQuery = jest.fn( () => ( { on: mockOn, off: mockOff } ) );
+( global as any ).jQuery = mockJQuery;
+
+// Mock @wordpress/i18n
+jest.mock( '@wordpress/i18n', () => ( {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ __: ( text: string ) => text,
+} ) );
+
+/**
+ * Internal dependencies
+ */
+import {
+ PaymentMethodSelect,
+ fetchUserTokens,
+ clearTokenCache,
+} from '../index';
+import type { Token } from '../types';
+
+describe( 'PaymentMethodSelect Component', () => {
+ const mockTokens: Token[] = [
+ { tokenId: 1, displayName: 'Visa •••• 1234', isDefault: true },
+ { tokenId: 2, displayName: 'Mastercard •••• 5678', isDefault: false },
+ { tokenId: 3, displayName: 'Amex •••• 9012', isDefault: false },
+ ];
+
+ const tokensWithoutDefault: Token[] = [
+ { tokenId: 1, displayName: 'Visa •••• 1234', isDefault: false },
+ { tokenId: 2, displayName: 'Mastercard •••• 5678', isDefault: false },
+ ];
+
+ beforeEach( () => {
+ jest.clearAllMocks();
+ clearTokenCache();
+ document.body.innerHTML = '';
+ } );
+
+ describe( 'Initial Rendering States', () => {
+ test( 'renders "please select customer" message when userId is 0', () => {
+ render(
+
+ );
+
+ expect(
+ screen.getByText( 'Please select a customer first' )
+ ).toBeInTheDocument();
+ } );
+
+ test( 'renders "please select customer" message when userId is negative', () => {
+ render(
+
+ );
+
+ expect(
+ screen.getByText( 'Please select a customer first' )
+ ).toBeInTheDocument();
+ } );
+
+ test( 'renders placeholder when userId > 0 but cache is empty', () => {
+ // When userId > 0 but cache has no tokens, component shows
+ // empty select with placeholder (not loading state)
+ // because the component only fetches on customer select change
+ render(
+
+ );
+
+ expect(
+ screen.getByText( 'Please select a payment method' )
+ ).toBeInTheDocument();
+ } );
+ } );
+
+ describe( 'Loading State', () => {
+ test( 'shows loading when customer select change triggers fetch', async () => {
+ // Mock fetch to never resolve during initial check
+ global.fetch = jest.fn(
+ () =>
+ new Promise( () => {
+ // Never resolves - simulates slow network
+ } )
+ );
+
+ document.body.innerHTML =
+ '';
+
+ render(
+
+ );
+
+ // Initially shows "select customer" since userId is 0
+ expect(
+ screen.getByText( 'Please select a customer first' )
+ ).toBeInTheDocument();
+
+ // Trigger customer selection change
+ const select = document.getElementById(
+ 'customer_user'
+ ) as HTMLSelectElement;
+ select.value = '1';
+
+ await act( async () => {
+ select.dispatchEvent( new Event( 'change' ) );
+ } );
+
+ // Now should show loading (fetch is pending)
+ expect( screen.getByText( 'Loading…' ) ).toBeInTheDocument();
+ } );
+
+ test( 'shows tokens after fetch resolves', async () => {
+ global.fetch = jest.fn().mockResolvedValue( {
+ ok: true,
+ json: () => Promise.resolve( { data: { tokens: mockTokens } } ),
+ } );
+
+ document.body.innerHTML =
+ '';
+
+ render(
+
+ );
+
+ // Trigger customer selection change
+ const select = document.getElementById(
+ 'customer_user'
+ ) as HTMLSelectElement;
+ select.value = '1';
+
+ await act( async () => {
+ select.dispatchEvent( new Event( 'change' ) );
+ } );
+
+ // Should show tokens after fetch resolves
+ await waitFor( () => {
+ expect(
+ screen.getByText( 'Visa •••• 1234' )
+ ).toBeInTheDocument();
+ } );
+ } );
+ } );
+
+ describe( 'Error State', () => {
+ test( 'shows error message when fetch fails', async () => {
+ global.fetch = jest.fn().mockResolvedValue( {
+ ok: false,
+ } );
+
+ document.body.innerHTML =
+ '';
+
+ render(
+
+ );
+
+ // Trigger customer selection change
+ const select = document.getElementById(
+ 'customer_user'
+ ) as HTMLSelectElement;
+ select.value = '1';
+
+ await act( async () => {
+ select.dispatchEvent( new Event( 'change' ) );
+ } );
+
+ await waitFor( () => {
+ expect(
+ screen.getByText( 'Failed to fetch user tokens' )
+ ).toBeInTheDocument();
+ } );
+ } );
+ } );
+
+ describe( 'Token Rendering', () => {
+ test( 'renders tokens after customer select change', async () => {
+ global.fetch = jest.fn().mockResolvedValue( {
+ ok: true,
+ json: () => Promise.resolve( { data: { tokens: mockTokens } } ),
+ } );
+
+ document.body.innerHTML =
+ '';
+
+ render(
+
+ );
+
+ // Trigger customer selection change
+ const select = document.getElementById(
+ 'customer_user'
+ ) as HTMLSelectElement;
+ select.value = '1';
+
+ await act( async () => {
+ select.dispatchEvent( new Event( 'change' ) );
+ } );
+
+ await waitFor( () => {
+ expect(
+ screen.getByText( 'Visa •••• 1234' )
+ ).toBeInTheDocument();
+ } );
+
+ expect(
+ screen.getByText( 'Mastercard •••• 5678' )
+ ).toBeInTheDocument();
+ expect( screen.getByText( 'Amex •••• 9012' ) ).toBeInTheDocument();
+ } );
+
+ test( 'auto-selects default token after fetch', async () => {
+ global.fetch = jest.fn().mockResolvedValue( {
+ ok: true,
+ json: () => Promise.resolve( { data: { tokens: mockTokens } } ),
+ } );
+
+ document.body.innerHTML =
+ '';
+
+ render(
+
+ );
+
+ // Trigger customer selection change
+ const customerSelect = document.getElementById(
+ 'customer_user'
+ ) as HTMLSelectElement;
+ customerSelect.value = '1';
+
+ await act( async () => {
+ customerSelect.dispatchEvent( new Event( 'change' ) );
+ } );
+
+ await waitFor( () => {
+ // Use getAllByRole since there are two comboboxes (customer_user and payment_method)
+ const selects = screen.getAllByRole( 'combobox' );
+ const paymentSelect = selects.find(
+ ( s ) => s.getAttribute( 'name' ) === 'payment_method'
+ ) as HTMLSelectElement;
+ // Default token (tokenId: 1) should be auto-selected
+ expect( paymentSelect.value ).toBe( '1' );
+ } );
+
+ // Placeholder should not be shown
+ expect(
+ screen.queryByText( 'Please select a payment method' )
+ ).not.toBeInTheDocument();
+ } );
+
+ test( 'shows placeholder when no default token', async () => {
+ global.fetch = jest.fn().mockResolvedValue( {
+ ok: true,
+ json: () =>
+ Promise.resolve( {
+ data: { tokens: tokensWithoutDefault },
+ } ),
+ } );
+
+ document.body.innerHTML =
+ '';
+
+ render(
+
+ );
+
+ // Trigger customer selection change
+ const customerSelect = document.getElementById(
+ 'customer_user'
+ ) as HTMLSelectElement;
+ customerSelect.value = '1';
+
+ await act( async () => {
+ customerSelect.dispatchEvent( new Event( 'change' ) );
+ } );
+
+ await waitFor( () => {
+ expect(
+ screen.getByText( 'Please select a payment method' )
+ ).toBeInTheDocument();
+ } );
+ } );
+
+ test( 'placeholder option is disabled', async () => {
+ global.fetch = jest.fn().mockResolvedValue( {
+ ok: true,
+ json: () =>
+ Promise.resolve( {
+ data: { tokens: tokensWithoutDefault },
+ } ),
+ } );
+
+ document.body.innerHTML =
+ '';
+
+ render(
+
+ );
+
+ // Trigger customer selection change
+ const customerSelect = document.getElementById(
+ 'customer_user'
+ ) as HTMLSelectElement;
+ customerSelect.value = '1';
+
+ await act( async () => {
+ customerSelect.dispatchEvent( new Event( 'change' ) );
+ } );
+
+ await waitFor( () => {
+ const placeholderOption = screen.getByText(
+ 'Please select a payment method'
+ ) as HTMLOptionElement;
+ expect( placeholderOption ).toHaveAttribute( 'disabled' );
+ expect( placeholderOption ).toHaveAttribute( 'value', '0' );
+ } );
+ } );
+
+ test( 'uses initial value when provided', () => {
+ // When initialValue is provided and cache is empty,
+ // the component renders with the initial value
+ render(
+
+ );
+
+ const select = screen.getByRole( 'combobox' ) as HTMLSelectElement;
+ // Since cache is empty, select will have no options but defaultValue is 2
+ expect( select ).toHaveAttribute( 'name', 'payment_method' );
+ } );
+ } );
+
+ describe( 'Cache Behavior', () => {
+ test( 'does not fetch again when cache already has tokens', async () => {
+ const fetchMock = jest.fn().mockResolvedValue( {
+ ok: true,
+ json: () => Promise.resolve( { data: { tokens: mockTokens } } ),
+ } );
+ global.fetch = fetchMock;
+
+ document.body.innerHTML =
+ '';
+
+ render(
+
+ );
+
+ // First customer selection - should fetch
+ const customerSelect = document.getElementById(
+ 'customer_user'
+ ) as HTMLSelectElement;
+ customerSelect.value = '1';
+
+ await act( async () => {
+ customerSelect.dispatchEvent( new Event( 'change' ) );
+ } );
+
+ await waitFor( () => {
+ expect(
+ screen.getByText( 'Visa •••• 1234' )
+ ).toBeInTheDocument();
+ } );
+
+ expect( fetchMock ).toHaveBeenCalledTimes( 1 );
+
+ // Change to a different user to reset state
+ customerSelect.value = '2';
+
+ await act( async () => {
+ customerSelect.dispatchEvent( new Event( 'change' ) );
+ } );
+
+ // This should fetch for user 2
+ await waitFor( () => {
+ expect( fetchMock ).toHaveBeenCalledTimes( 2 );
+ } );
+
+ // Change back to user 1 - should NOT fetch again (cached)
+ customerSelect.value = '1';
+
+ await act( async () => {
+ customerSelect.dispatchEvent( new Event( 'change' ) );
+ } );
+
+ // Tokens should be available immediately from cache
+ expect( screen.getByText( 'Visa •••• 1234' ) ).toBeInTheDocument();
+
+ // Fetch should not have been called again
+ expect( fetchMock ).toHaveBeenCalledTimes( 2 );
+ } );
+ } );
+} );
+
+describe( 'fetchUserTokens', () => {
+ const originalFetch = global.fetch;
+
+ afterEach( () => {
+ global.fetch = originalFetch;
+ } );
+
+ test( 'sends correct request parameters', async () => {
+ let capturedUrl = '';
+ let capturedOptions: RequestInit | undefined;
+
+ global.fetch = jest.fn().mockImplementation( ( url, options ) => {
+ capturedUrl = url;
+ capturedOptions = options;
+ return Promise.resolve( {
+ ok: true,
+ json: () => Promise.resolve( { data: { tokens: [] } } ),
+ } );
+ } );
+
+ await fetchUserTokens( 123, 'http://test.com/ajax', 'test-nonce' );
+
+ expect( capturedUrl ).toBe( 'http://test.com/ajax' );
+ expect( capturedOptions?.method ).toBe( 'POST' );
+
+ const formData = capturedOptions?.body as FormData;
+ expect( formData.get( 'action' ) ).toBe(
+ 'wcpay_get_user_payment_tokens'
+ );
+ expect( formData.get( 'nonce' ) ).toBe( 'test-nonce' );
+ expect( formData.get( 'user_id' ) ).toBe( '123' );
+ } );
+
+ test( 'returns tokens on successful response', async () => {
+ const mockTokens: Token[] = [
+ { tokenId: 1, displayName: 'Visa •••• 1234', isDefault: true },
+ ];
+
+ global.fetch = jest.fn().mockResolvedValue( {
+ ok: true,
+ json: () => Promise.resolve( { data: { tokens: mockTokens } } ),
+ } );
+
+ const result = await fetchUserTokens(
+ 1,
+ 'http://test.com/ajax',
+ 'nonce'
+ );
+
+ expect( result ).toEqual( mockTokens );
+ } );
+
+ test( 'throws error when response is not ok', async () => {
+ global.fetch = jest.fn().mockResolvedValue( {
+ ok: false,
+ } );
+
+ await expect(
+ fetchUserTokens( 1, 'http://test.com/ajax', 'nonce' )
+ ).rejects.toThrow( 'Failed to fetch user tokens' );
+ } );
+
+ test( 'throws error when response data is undefined', async () => {
+ global.fetch = jest.fn().mockResolvedValue( {
+ ok: true,
+ json: () => Promise.resolve( {} ),
+ } );
+
+ await expect(
+ fetchUserTokens( 1, 'http://test.com/ajax', 'nonce' )
+ ).rejects.toThrow(
+ 'Failed to fetch user tokens. Please reload the page and try again.'
+ );
+ } );
+} );
diff --git a/client/subscription-edit-page/index.tsx b/client/subscription-edit-page/index.tsx
new file mode 100644
index 00000000000..437113e7e52
--- /dev/null
+++ b/client/subscription-edit-page/index.tsx
@@ -0,0 +1,279 @@
+/* global jQuery */
+
+/**
+ * External dependencies
+ */
+import React, { useState, useEffect } from 'react';
+import { createRoot } from 'react-dom/client';
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import type {
+ PaymentMethodSelectProps,
+ WCPayPMSelectorData,
+ Token,
+} from './types';
+
+/**
+ * Cache for all tokens, may be shared between multiple selects.
+ */
+const cachedTokens = new Map< number, Token[] >();
+
+/**
+ * Clears the token cache. Exported for testing purposes.
+ */
+export const clearTokenCache = (): void => {
+ cachedTokens.clear();
+};
+
+/**
+ * Fetch the tokens for a user from the back-end.
+ *
+ * @param {number} userId The user ID.
+ * @param {string} ajaxUrl The AJAX URL.
+ * @param {string} nonce The nonce.
+ * @return {Promise} The tokens for the user.
+ * @throws {Error} If the tokens cannot be fetched or the response is invalid.
+ */
+export const fetchUserTokens = async (
+ userId: number,
+ ajaxUrl: string,
+ nonce: string
+): Promise< Token[] > => {
+ const formData = new FormData();
+ formData.append( 'action', 'wcpay_get_user_payment_tokens' );
+ formData.append( 'nonce', nonce );
+ formData.append( 'user_id', userId.toString() );
+
+ const response = await fetch( ajaxUrl, {
+ method: 'POST',
+ body: formData,
+ } );
+ if ( ! response.ok ) {
+ throw new Error(
+ __( 'Failed to fetch user tokens', 'woocommerce-payments' )
+ );
+ }
+
+ const result = await response.json();
+ const data = result.data as { tokens: Token[] };
+
+ if ( undefined === data ) {
+ throw new Error(
+ __(
+ 'Failed to fetch user tokens. Please reload the page and try again.',
+ 'woocommerce-payments'
+ )
+ );
+ }
+
+ return data.tokens;
+};
+
+/**
+ * Add a listener to the customer select.
+ *
+ * This could be a shorter method, but because the customer select
+ * element uses select2, it does not emit the typical `change` event.
+ *
+ * @return {() => void} The cleanup function.
+ */
+export const addCustomerSelectListener = (
+ callback: ( userId: number ) => void
+): ( () => void ) => {
+ const element = document.getElementById( 'customer_user' );
+ const customerUserSelect =
+ element instanceof HTMLSelectElement ? element : null;
+
+ if ( ! customerUserSelect ) {
+ return (): void => {
+ // No-op cleanup function when an element is not found.
+ };
+ }
+
+ // Wrap in an internal callback to load the select's value and tokens.
+ const internalCallback = async () => {
+ callback( parseInt( customerUserSelect.value, 10 ) || 0 );
+ };
+
+ // Add the listener with the right technique, as select2 does not emit