08 — Checkout Customization Challenge
Context: This is an actual coding challenge from the Glomopay interview process. Build a checkout customization page with a form, live preview, mock API, dirty tracking, save/reset, and validation.
Requirements
- Two-column layout: form on the left, live preview on the right
- Form fields: button text (text input) and background color (hex color input + picker)
- Live preview updates as the user types (no submit needed for preview)
- Mock API:
fetchCustomisation()loads initial values,saveCustomisation()persists changes - Dirty tracking: Save/Reset buttons only enabled when form differs from saved state
- Validation: button text required (min 3 chars), background color must be valid
#rrggbb - Save disabled when form is invalid or clean
- Reset reverts to last saved values (no API call)
Architecture
CustomisationPage ← layout orchestrator, calls useCustomisation hook
├── CustomisationForm ← form inputs, error display, button state
└── CheckoutPreview ← pure presentational, receives buttonText + backgroundColorThree components + one custom hook. No Context — the tree is flat enough that props work cleanly.
Why useState over useReducer
The practice-checkout project already demonstrates useReducer + Context for a multi-step checkout flow. This project intentionally uses useState to show when the simpler tool is the right choice:
- Two fields — the state shape is flat, not a complex object with many transitions
- No complex transitions — no discriminated union of action types needed
- Derived state handles the complexity —
isDirty,errors, andisValidare computed each render, not stored
Key Patterns
Derived State (not stored)
// Computed every render — never out of sync
const isDirty =
formValues.buttonText !== savedValues.buttonText ||
formValues.backgroundColor !== savedValues.backgroundColor;
const errors = validate(formValues);
const isValid = !hasErrors(errors);Why: Storing derived state creates sync bugs. If isDirty were in useState, you'd need to update it every time formValues or savedValues change — easy to miss one.
Form vs Saved Diffing
The hook maintains two pieces of state:
formValues— what the user sees in the form right nowsavedValues— what the API last returned
This creates a clean mental model:
- Reset = copy
savedValuesintoformValues - Save = send
formValuesto API, update both from response - isDirty =
formValues !== savedValues(field-by-field comparison)
Pure Validation Functions
export function validateButtonText(value: string): string | undefined {
if (!value.trim()) return 'Button text is required';
if (value.trim().length < 3) return 'Button text must be at least 3 characters';
return undefined;
}Validation is extracted as pure functions — no hooks, no state, no side effects. This makes them trivially testable and reusable.
Presentational vs Container Split
CheckoutPreviewis purely presentational — receives props, renders UI, no stateCustomisationFormis also presentational — receives values, errors, and callbacks as propsCustomisationPageis the container — calls the hook, passes data down
This follows the classic container/presentational pattern. The form doesn't know about the API or the hook. It just renders what it's given and calls back when things change.
What the Interviewer Is Testing
| Area | What they want to see |
|---|---|
| React + TypeScript | Proper typing of props, state, and API responses |
| State management | Choosing the right tool (useState vs useReducer), derived state, avoiding sync bugs |
| Component architecture | Clean separation of concerns, testable components |
| Form handling | Validation, dirty tracking, error display, button state |
| Testing | Behavior-driven tests, async handling, user interaction simulation |
| SOLID | Single responsibility (hook vs components vs validation), open/closed (validation is extensible) |
Running the Practice Project
cd glomopay-prep/practice-customization
npm install
npm run dev # Start dev server
npm test # Run tests