A wizard-style form that validates per step, preserves state across steps, and supports going back.
Illustration coming soon
HOW TO USE THIS CHALLENGE
1. Read the briefClarify decisions before coding.
2. Build from memoryUse the 90-minute target.
3. Study the guideCompare architecture, tests, and trade-offs.
REQUIREMENTS
Break a long form into sequential steps with a visible progress indicator.
Validate the current step before allowing 'Next'; show inline field errors.
Preserve all previously entered data when navigating back and forth between steps.
Support jumping directly to a previously completed step via the progress indicator.
Submit the full, combined data only from the final step.
EDGE CASES
Going back to fix a field shouldn't clear data entered on later steps.
A required field left empty should block only that step's progression, with focus moved to the first invalid field.
Refreshing mid-wizard: decide and implement whether progress persists (e.g. via sessionStorage) or intentionally resets, and make that behavior explicit.
Submitting should be disabled (or debounced) to prevent duplicate submissions from a double-click.
ACCESSIBILITY
Announce step changes (e.g. 'Step 2 of 4: Shipping details') via a live region or by moving focus to the new step's heading.
Each step's fields keep proper label associations and error messages linked via aria-describedby.
The step indicator, if clickable, exposes current/completed/upcoming state via aria-current or equivalent, and is keyboard-operable.
SUGGESTED APPROACH
Hold all form data in one object at the wizard level (or a form-library instance scoped to the whole flow), not separate state per step, so nothing is lost switching steps.
Define each step's required fields and a pure validate(step, data) function, run before allowing navigation forward.
Render only the active step's fields, keyed by step id, while keeping the shared data object as the single source of truth.
Move focus to the step heading (or first invalid field on a failed validation) after every step transition for both usability and accessibility.
EVALUATION RUBRIC
Data survives backward/forward navigation between all steps without loss.
Final submission only fires once, from the last step, with the complete combined data.
Step transitions manage focus deliberately rather than leaving it stranded.
01Understand the product before coding
Learning goals
Break a long form into sequential steps with a visible progress indicator.
Validate the current step before allowing 'Next'; show inline field errors.
Preserve all previously entered data when navigating back and forth between steps.
Support jumping directly to a previously completed step via the progress indicator.
Decisions to state aloud
Going back to fix a field shouldn't clear data entered on later steps.
A required field left empty should block only that step's progression, with focus moved to the first invalid field.
Refreshing mid-wizard: decide and implement whether progress persists (e.g. via sessionStorage) or intentionally resets, and make that behavior explicit.
Submitting should be disabled (or debounced) to prevent duplicate submissions from a double-click.
02State model and invariants
One wizard state owns all values, touched fields, errors, current step, completed steps, and submission status. Step definitions list fields and validation; current visibility is derived. A discriminated submit state prevents simultaneous submitting and success UI.
01Wizard reducer owns cross-step data and transitions.
02StepRegistry defines fields, labels, validation, and next-step logic.
03StepIndicator exposes current and completed navigation.
04Field components connect label, hint, error, and value.
05Submission boundary maps server field and form errors.
04Reference implementation walkthrough
Step 1
Define steps as data
Give every step a stable id, field list, and pure validator. Conditional branching chooses the next id from current values rather than relying on numeric indexes.
Next marks current fields touched, computes errors, blocks and focuses the first invalid field, or marks the step completed and moves focus to the next heading.
TSX
1const errors =validateStep(stepId, state.values);2if(Object.keys(errors).length){3dispatch({ type:'validationFailed', errors });4requestAnimationFrame(()=>focusField(Object.keys(errors)[0]));5return;6}7dispatch({ type:'advanced', to: steps[stepId].next(state.values)!});
Step 3
Persist deliberately
Version session drafts, store only necessary non-sensitive values, migrate or discard incompatible versions, and never persist payment secrets. Completion clears the draft.
Step 4
Make final submission idempotent
Guard duplicate activation in the client and use an idempotency key on the server. Map authoritative field errors back to their owning step and focus an error summary.
SOLComplete solution, explained simply
Build it yourself first. This is one correct implementation, not the only one — read it top to bottom, then compare the shape of your version.
We are building a checkout wizard where the path can branch (US buyers get a tax step, others skip it), your data is never lost when a step unmounts, each step is validated only when you try to leave it, and submitting twice cannot create two orders. The one big idea: describe the steps as data — each step lists its fields, its own validator, and a function that decides the next step from the current answers — and keep every value in one shared state object.
Wizard.tsx
1import{ useReducer, useRef }from"react";23type Values={email: string; address: string; country: string; taxId: string };4type StepId="account"|"shipping"|"tax"|"review";5type Errors=Partial<Record<keyof Values, string>>;6type Step={7title: string;8fields:(keyof Values)[];9validate:(v:Values)=>Errors;10next:(v:Values)=>StepId|null;// null means 'this is the last step'11};1213conststeps:Record<StepId,Step>={14account:{15title:"Account",16fields:["email"],17validate:(v)=>(/^[^@\s]+@[^@\s]+$/.test(v.email)?{}:{email:"Enter a valid email"}),18next:()=>"shipping",19},20shipping:{21title:"Shipping",22fields:["address","country"],23validate:(v)=>({24...(v.address?{}:{address:"Address is required"}),25...(v.country?{}:{country:"Country is required"}),26}),27next:(v)=>(v.country==="US"?"tax":"review"),28},29tax:{30title:"Tax",31fields:["taxId"],32validate:(v)=>(v.taxId?{}:{taxId:"Tax ID is required in the US"}),33next:()=>"review",34},35review:{title:"Review",fields:[],validate:()=>({}),next:()=>null},36};3738type Submit=39|{status:"idle"}|{status:"submitting"}40|{status:"error"; message: string }|{status:"success"; orderId: string };41type State={values:Values; errors:Errors; stepId:StepId; history:StepId[]; submit:Submit};42type Action=43|{type:"change"; field: keyof Values; value: string }44|{type:"invalid"; errors:Errors}45|{type:"advance"; to:StepId}46|{type:"back"}47|{type:"submit"; state:Submit};4849functionreducer(state:State,action:Action):State{50switch(action.type){51case"change":52return{53...state,54values:{...state.values,[action.field]: action.value},55errors:{...state.errors,[action.field]:undefined},56};57case"invalid":58return{...state,errors: action.errors};59case"advance":60return{...state,stepId: action.to,history:[...state.history, state.stepId],errors:{}};61case"back":{62const history =[...state.history];63const prev = history.pop();64return prev ?{...state,stepId: prev, history,errors:{}}: state;65}66case"submit":67return{...state,submit: action.state};68}69}7071exportfunctionWizard({ placeOrder }:{72placeOrder:(v:Values,idempotencyKey: string)=>Promise<{orderId: string }>;73}){74const[state, dispatch]=useReducer(reducer,{75values:{email:"",address:"",country:"",taxId:""},76errors:{},stepId:"account",history:[],submit:{status:"idle"},77});78const step = steps[state.stepId];79const idempotencyKey =useRef(crypto.randomUUID()).current;// same key for every retry8081functionfocusFirstError(errors:Errors){82const first =Object.keys(errors)[0];83if(first)requestAnimationFrame(()=>84document.querySelector<HTMLElement>('[name="'+ first +'"]')?.focus(),85);86}8788asyncfunctionsubmit(){89if(state.submit.status==="submitting")return;// client-side double-click guard90dispatch({type:"submit",state:{status:"submitting"}});91try{92const{ orderId }=awaitplaceOrder(state.values, idempotencyKey);93dispatch({type:"submit",state:{status:"success", orderId }});94}catch{95dispatch({type:"submit",state:{status:"error",message:"Could not place the order."}});96}97}9899functiononNext(){100const errors = step.validate(state.values);101if(Object.keys(errors).length>0){102dispatch({type:"invalid", errors });103focusFirstError(errors);104return;105}106const to = step.next(state.values);107if(to)dispatch({type:"advance", to });108elsesubmit();109}110111if(state.submit.status==="success"){112return<p role="status">Order{state.submit.orderId} placed.</p>;113}114115return(116<form onSubmit={(e)=>{ e.preventDefault();onNext();}}>117<ol aria-label="Progress">118{(Object.keys(steps)asStepId[]).map((id)=>(119<li key={id} aria-current={id === state.stepId?"step":undefined}>{steps[id].title}</li>120))}121</ol>122123<h2>{step.title}</h2>124125{step.fields.map((field)=>(126<label key={field}>127{field}128<input129 name={field}130 value={state.values[field]}131 aria-invalid={Boolean(state.errors[field])}132 onChange={(e)=>dispatch({type:"change", field,value: e.target.value})}133/>134{state.errors[field]&&<span role="alert">{state.errors[field]}</span>}135</label>136))}137138{state.submit.status==="error"&&<p role="alert">{state.submit.message}</p>}139140<button type="button" disabled={state.history.length===0} onClick={()=>dispatch({type:"back"})}>141Back142</button>143<button type="submit" disabled={state.submit.status==="submitting"}>144{step.next(state.values)===null?"Place order":"Next"}145</button>146</form>147);148}
How each part works
Steps are data, not components
The steps object maps each step id to its title, its fields, a pure validate function, and a next function. Nothing about the flow is hard-coded in JSX. Adding, removing, or reordering a step is a data edit, and the form renders whatever the current step's fields list says.
Branching uses next(values), not a number
shipping.next returns 'tax' only when country is US, otherwise 'review'. Because the path is computed from answers, a numeric 'step 3' index would be meaningless — someone in France and someone in the US are on different steps at the same point. history is a stack of the ids actually visited, so Back always returns to the real previous step.
One shared values object
Every field for every step lives in state.values from the start. When a step unmounts, its data is untouched. Cross-step rules (like 'tax id only if US') can read every answer, and the final submit already has the complete object with nothing to reassemble.
Validation happens on the transition
onNext calls the current step's validate. If it returns any errors, we store them, move focus to the first invalid field, and stop — we do not advance. Only a clean validation lets step.next run. Typing in a field clears just that field's error.
Submit is guarded twice
The client check returns early if status is already 'submitting', so a fast double-click does nothing. The idempotencyKey — generated once and reused for every retry — is sent to the server, which uses it to recognize a repeat and return the same order instead of creating a second one. Client guards alone cannot guarantee this.
A discriminated submit state
submit is exactly one of idle, submitting, error, or success. The button is disabled while submitting, the error shows only in the error state, and the whole form is replaced by a confirmation in the success state. There is no way to show 'submitting' and 'success' at once.
Why this is correct
Describing steps as data (fields, validator, next function) makes a branching flow a data structure instead of tangled conditional JSX.
The next step is computed from the answers, so stable step ids and a visited-history stack replace numeric indexes.
All values live in one object for the whole flow, so unmounting a step never loses data and cross-step rules see everything.
Validate a step only when the user tries to leave it, then block and focus the first invalid field.
Prevent duplicate orders with a client guard for responsiveness plus a server idempotency key for the real guarantee.
05Testing strategy
Critical behavior
Data survives backward/forward navigation between all steps without loss.
Final submission only fires once, from the last step, with the complete combined data.
Step transitions manage focus deliberately rather than leaving it stranded.
Failure and boundary cases
Going back to fix a field shouldn't clear data entered on later steps.
A required field left empty should block only that step's progression, with focus moved to the first invalid field.
Refreshing mid-wizard: decide and implement whether progress persists (e.g. via sessionStorage) or intentionally resets, and make that behavior explicit.
Submitting should be disabled (or debounced) to prevent duplicate submissions from a double-click.
Accessibility
Announce step changes (e.g. 'Step 2 of 4: Shipping details') via a live region or by moving focus to the new step's heading.
Each step's fields keep proper label associations and error messages linked via aria-describedby.
The step indicator, if clickable, exposes current/completed/upcoming state via aria-current or equivalent, and is keyboard-operable.
06Performance and production hardening
Keep validation pure and scoped to relevant fields.
Avoid rerendering every field on one keystroke through granular subscriptions where measured.
Lazy-load genuinely heavy optional steps.
Persist drafts with a bounded debounce, not every character synchronously.
QAInterview questions and model answers
Answer aloud first. Then open the model answer and compare state ownership, failure handling, accessibility, and trade-offs—not exact wording.
Model answer
Unmounted steps cannot lose values, cross-step validation sees one source of truth, and final submission uses an already combined model.