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..e264b819176
--- /dev/null
+++ b/client/subscription-edit-page/__tests__/index.test.tsx
@@ -0,0 +1,532 @@
+/* eslint-disable prettier/prettier */
+/** @format */
+/**
+ * External dependencies
+ */
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import React from 'react';
+
+/**
+ * Internal dependencies
+ */
+import { PaymentMethodSelect } from '../index';
+import UserTokenCache from '../user-token-cache';
+import type { Token } from '../types';
+
+// Mock jQuery
+const mockJQuery = jest.fn();
+const mockOn = jest.fn();
+const mockOff = jest.fn();
+( global as any ).jQuery = mockJQuery;
+mockJQuery.mockReturnValue( { on: mockOn, off: mockOff } );
+
+// Mock fetch
+const mockFetch = jest.fn();
+global.fetch = mockFetch;
+
+// Mock @wordpress/i18n
+jest.mock( '@wordpress/i18n', () => ( {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ __: ( text: string ) => text,
+} ) );
+
+describe( 'PaymentMethodSelect Component', () => {
+ const mockTokens: Token[] = [
+ { tokenId: 1, displayName: 'Visa •••• 1234' },
+ { tokenId: 2, displayName: 'Mastercard •••• 5678' },
+ { tokenId: 3, displayName: 'Amex •••• 9012' },
+ ];
+
+ let cache: UserTokenCache;
+ let mockOnChange: jest.Mock;
+
+ beforeEach( () => {
+ jest.clearAllMocks();
+ cache = new UserTokenCache();
+ mockOnChange = jest.fn();
+ } );
+
+ describe( 'Rendering States', () => {
+ test( 'renders select with tokens', () => {
+ cache.add( 1, mockTokens );
+
+ render(
+
+ );
+
+ const select = screen.getByRole( 'combobox' );
+ expect( select ).toBeInTheDocument();
+ expect( select ).toHaveAttribute( 'name', 'payment_method' );
+ expect( select ).toHaveValue( '1' );
+
+ mockTokens.forEach( ( token ) => {
+ expect(
+ screen.getByText( token.displayName )
+ ).toBeInTheDocument();
+ } );
+ } );
+
+ test( 'renders loading state', () => {
+ cache.startLoading( 1 );
+
+ render(
+
+ );
+
+ expect( screen.getByText( 'Loading…' ) ).toBeInTheDocument();
+ } );
+
+ test( 'renders error state', () => {
+ cache.startLoading( 1 );
+ cache.loadingFailed( 1, 'Failed to fetch user tokens' );
+
+ render(
+
+ );
+
+ expect(
+ screen.getByText( 'Failed to fetch user tokens' )
+ ).toBeInTheDocument();
+ } );
+
+ test( 'renders no customer selected message', () => {
+ render(
+
+ );
+
+ expect(
+ screen.getByText( 'Please select a customer first' )
+ ).toBeInTheDocument();
+ } );
+
+ test( 'renders no customer selected message for undefined userId', () => {
+ render(
+
+ );
+
+ expect(
+ screen.getByText( 'Please select a customer first' )
+ ).toBeInTheDocument();
+ } );
+
+ test( 'renders placeholder when no tokens match value', () => {
+ cache.add( 1, mockTokens );
+
+ render(
+
+ );
+
+ expect(
+ screen.getByText( 'Please select a payment method' )
+ ).toBeInTheDocument();
+ } );
+
+ test( 'renders empty token list', () => {
+ cache.add( 1, [] );
+
+ render(
+
+ );
+
+ const select = screen.getByRole( 'combobox' );
+ expect( select ).toBeInTheDocument();
+ } );
+ } );
+
+ describe( 'User Interaction', () => {
+ test( 'calls onChange when user selects a payment method', async () => {
+ cache.add( 1, mockTokens );
+
+ render(
+
+ );
+
+ const select = screen.getByRole( 'combobox' ) as HTMLSelectElement;
+
+ await userEvent.selectOptions( select, '2' );
+
+ expect( mockOnChange ).toHaveBeenCalledWith( 2 );
+ } );
+
+ test( 'placeholder option is disabled', () => {
+ cache.add( 1, mockTokens );
+
+ render(
+
+ );
+
+ const placeholderOption = screen.getByText(
+ 'Please select a payment method'
+ ) as HTMLOptionElement;
+
+ expect( placeholderOption ).toHaveAttribute( 'disabled' );
+ expect( placeholderOption ).toHaveAttribute( 'value', '0' );
+ } );
+ } );
+
+ describe( 'Value Display', () => {
+ test( 'displays correct initial value', () => {
+ cache.add( 1, mockTokens );
+
+ render(
+
+ );
+
+ const select = screen.getByRole( 'combobox' ) as HTMLSelectElement;
+ expect( select.value ).toBe( '2' );
+ } );
+
+ test( 'updates when value prop changes', () => {
+ cache.add( 1, mockTokens );
+
+ const { rerender } = render(
+
+ );
+
+ let select = screen.getByRole( 'combobox' ) as HTMLSelectElement;
+ expect( select.value ).toBe( '1' );
+
+ rerender(
+
+ );
+
+ select = screen.getByRole( 'combobox' ) as HTMLSelectElement;
+ expect( select.value ).toBe( '2' );
+ } );
+ } );
+} );
+
+describe( 'UserTokenCache', () => {
+ let cache: UserTokenCache;
+ const mockTokens: Token[] = [
+ { tokenId: 1, displayName: 'Visa •••• 1234' },
+ { tokenId: 2, displayName: 'Mastercard •••• 5678' },
+ ];
+
+ beforeEach( () => {
+ cache = new UserTokenCache();
+ } );
+
+ describe( 'add()', () => {
+ test( 'adds user with tokens to cache', () => {
+ cache.add( 1, mockTokens );
+
+ expect( cache.hasEntry( 1 ) ).toBe( true );
+ const entry = cache.getUserEntry( 1 );
+ expect( entry ).toEqual( {
+ userId: 1,
+ tokens: mockTokens,
+ loading: false,
+ loadingError: null,
+ } );
+ } );
+
+ test( 'adds user with empty tokens', () => {
+ cache.add( 1, [] );
+
+ expect( cache.hasEntry( 1 ) ).toBe( true );
+ const entry = cache.getUserEntry( 1 );
+ expect( entry?.tokens ).toEqual( [] );
+ } );
+
+ test( 'can add multiple users', () => {
+ cache.add( 1, mockTokens );
+ cache.add( 2, [] );
+
+ expect( cache.hasEntry( 1 ) ).toBe( true );
+ expect( cache.hasEntry( 2 ) ).toBe( true );
+ } );
+ } );
+
+ describe( 'startLoading()', () => {
+ test( 'adds user in loading state', () => {
+ cache.startLoading( 1 );
+
+ const entry = cache.getUserEntry( 1 );
+ expect( entry ).toEqual( {
+ userId: 1,
+ loading: true,
+ loadingError: null,
+ tokens: [],
+ } );
+ } );
+
+ test( 'adds loading state for new user', () => {
+ cache.add( 1, mockTokens );
+ cache.startLoading( 2 );
+
+ expect( cache.hasEntry( 1 ) ).toBe( true );
+ expect( cache.hasEntry( 2 ) ).toBe( true );
+ expect( cache.getUserEntry( 2 )?.loading ).toBe( true );
+ } );
+ } );
+
+ describe( 'tokensLoaded()', () => {
+ test( 'updates loading entry with tokens', () => {
+ cache.startLoading( 1 );
+ cache.tokensLoaded( 1, mockTokens );
+
+ const entry = cache.getUserEntry( 1 );
+ expect( entry ).toEqual( {
+ userId: 1,
+ tokens: mockTokens,
+ loading: false,
+ loadingError: null,
+ } );
+ } );
+
+ test( 'does not affect other users', () => {
+ cache.add( 1, mockTokens );
+ cache.startLoading( 2 );
+ cache.tokensLoaded( 2, [] );
+
+ const user1Entry = cache.getUserEntry( 1 );
+ expect( user1Entry?.tokens ).toEqual( mockTokens );
+ } );
+
+ test( 'clears loading state', () => {
+ cache.startLoading( 1 );
+ expect( cache.getUserEntry( 1 )?.loading ).toBe( true );
+
+ cache.tokensLoaded( 1, mockTokens );
+ expect( cache.getUserEntry( 1 )?.loading ).toBe( false );
+ } );
+ } );
+
+ describe( 'loadingFailed()', () => {
+ test( 'sets error message', () => {
+ cache.startLoading( 1 );
+ cache.loadingFailed( 1, 'Network error' );
+
+ const entry = cache.getUserEntry( 1 );
+ expect( entry ).toEqual( {
+ userId: 1,
+ tokens: [],
+ loading: false,
+ loadingError: 'Network error',
+ } );
+ } );
+
+ test( 'clears loading state', () => {
+ cache.startLoading( 1 );
+ cache.loadingFailed( 1, 'Error' );
+
+ expect( cache.getUserEntry( 1 )?.loading ).toBe( false );
+ } );
+
+ test( 'preserves existing tokens', () => {
+ cache.startLoading( 1 );
+ cache.loadingFailed( 1, 'Error' );
+
+ expect( cache.getUserEntry( 1 )?.tokens ).toEqual( [] );
+ } );
+ } );
+
+ describe( 'hasEntry()', () => {
+ test( 'returns true when user exists', () => {
+ cache.add( 1, mockTokens );
+
+ expect( cache.hasEntry( 1 ) ).toBe( true );
+ } );
+
+ test( 'returns false when user does not exist', () => {
+ expect( cache.hasEntry( 999 ) ).toBe( false );
+ } );
+
+ test( 'returns true for loading entries', () => {
+ cache.startLoading( 1 );
+
+ expect( cache.hasEntry( 1 ) ).toBe( true );
+ } );
+ } );
+
+ describe( 'getUserEntry()', () => {
+ test( 'returns user entry when exists', () => {
+ cache.add( 1, mockTokens );
+
+ const entry = cache.getUserEntry( 1 );
+ expect( entry?.userId ).toBe( 1 );
+ expect( entry?.tokens ).toEqual( mockTokens );
+ } );
+
+ test( 'returns undefined when user does not exist', () => {
+ const entry = cache.getUserEntry( 999 );
+
+ expect( entry ).toBeUndefined();
+ } );
+ } );
+
+ describe( 'userHasToken()', () => {
+ test( 'returns true when user has token', () => {
+ cache.add( 1, mockTokens );
+
+ expect( cache.userHasToken( 1, 1 ) ).toBe( true );
+ expect( cache.userHasToken( 1, 2 ) ).toBe( true );
+ } );
+
+ test( 'returns false when user does not have token', () => {
+ cache.add( 1, mockTokens );
+
+ expect( cache.userHasToken( 1, 999 ) ).toBe( false );
+ } );
+
+ test( 'returns false when user does not exist', () => {
+ expect( cache.userHasToken( 999, 1 ) ).toBe( false );
+ } );
+
+ test( 'returns false for user with empty tokens', () => {
+ cache.add( 1, [] );
+
+ expect( cache.userHasToken( 1, 1 ) ).toBe( false );
+ } );
+ } );
+
+ describe( 'subscribe()', () => {
+ test( 'calls subscriber when cache updates', () => {
+ const subscriber = jest.fn();
+ cache.subscribe( subscriber );
+
+ cache.add( 1, mockTokens );
+
+ expect( subscriber ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ test( 'calls all subscribers on update', () => {
+ const subscriber1 = jest.fn();
+ const subscriber2 = jest.fn();
+
+ cache.subscribe( subscriber1 );
+ cache.subscribe( subscriber2 );
+
+ cache.add( 1, mockTokens );
+
+ expect( subscriber1 ).toHaveBeenCalledTimes( 1 );
+ expect( subscriber2 ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ test( 'calls subscribers on startLoading', () => {
+ const subscriber = jest.fn();
+ cache.subscribe( subscriber );
+
+ cache.startLoading( 1 );
+
+ expect( subscriber ).toHaveBeenCalled();
+ } );
+
+ test( 'calls subscribers on tokensLoaded', () => {
+ const subscriber = jest.fn();
+ cache.subscribe( subscriber );
+
+ cache.startLoading( 1 );
+ subscriber.mockClear();
+
+ cache.tokensLoaded( 1, mockTokens );
+
+ expect( subscriber ).toHaveBeenCalled();
+ } );
+
+ test( 'calls subscribers on loadingFailed', () => {
+ const subscriber = jest.fn();
+ cache.subscribe( subscriber );
+
+ cache.startLoading( 1 );
+ subscriber.mockClear();
+
+ cache.loadingFailed( 1, 'Error' );
+
+ expect( subscriber ).toHaveBeenCalled();
+ } );
+ } );
+
+ describe( 'getCache()', () => {
+ test( 'returns current cache state', () => {
+ cache.add( 1, mockTokens );
+ cache.add( 2, [] );
+
+ const cacheState = cache.getCache();
+
+ expect( cacheState ).toHaveLength( 2 );
+ expect( cacheState[ 0 ].userId ).toBe( 1 );
+ expect( cacheState[ 1 ].userId ).toBe( 2 );
+ } );
+
+ test( 'returns empty array for new cache', () => {
+ const cacheState = cache.getCache();
+
+ expect( cacheState ).toEqual( [] );
+ } );
+ } );
+} );
diff --git a/client/subscription-edit-page/index.tsx b/client/subscription-edit-page/index.tsx
new file mode 100644
index 00000000000..c564d874fba
--- /dev/null
+++ b/client/subscription-edit-page/index.tsx
@@ -0,0 +1,280 @@
+/* eslint-disable prettier/prettier */
+/* global jQuery */
+
+/**
+ * External dependencies
+ */
+import React from 'react';
+import { createRoot } from 'react-dom/client';
+import { __ } from '@wordpress/i18n';
+
+// TypeScript declaration for jQuery
+declare const jQuery: (
+ selector: any
+) => {
+ on: ( event: string, handler: () => void ) => void;
+ off: ( event: string, handler: () => void ) => void;
+};
+
+/**
+ * Internal dependencies
+ */
+import type {
+ PaymentMethodSelectProps,
+ WCPayPMSelectorData,
+ FetchUserTokensResponse,
+} from './types';
+import UserTokenCache from './user-token-cache';
+
+/**
+ * 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.
+ *
+ * @param {(userId: number) => void} callback The callback to call when the customer is changed.
+ * @return {() => void} The cleanup function.
+ */
+const addCustomerSelectListener = (
+ callback: ( userId: number ) => void
+): ( () => void ) => {
+ const customerUserSelect = document.getElementById(
+ 'customer_user'
+ ) as HTMLSelectElement | null;
+
+ if ( ! customerUserSelect ) {
+ return (): void => {
+ // No-op cleanup function when element is not found
+ };
+ }
+
+ // Wrap in an internal callback to load the select's value.
+ const internalCallback = () =>
+ callback( parseInt( customerUserSelect.value, 10 ) || 0 );
+
+ // Add the listner with the right technique, as select2 does not emit