Start with one source of truth
A multi-step form is easier to reason about when the current step and every answer live in one state object. For a small contact form, use two steps: a required name on the first step and a required email address on the second. In a modern browser with JavaScript enabled, you can start with const state = { step: 0, answers: { name: '', email: '' } }. The step number controls what is visible; answers holds what the person has entered. Moving between steps changes step, not answers.
Create one form containing two step sections, each with a heading and a labeled input. Give the inputs the names name and email. Add Previous and Next buttons with type="button", plus a final button with type="submit". Give each step section a stable identifier so JavaScript can find it. This setup needs no framework or build tool: a browser can run the script as a module after the form markup. Keep the sections in a fixed order so step 0 always means name and step 1 always means email.
Render from state, then record edits
Write a render function that shows only the section matching state.step, hides the other section, and switches the navigation buttons appropriately. Set each input’s value from state.answers when rendering. That last action matters if a later change replaces a section’s markup: the displayed values can still be restored from state. For the simplest version, keep the same input elements in the document and toggle each section’s hidden property rather than rebuilding the markup on every click.
Listen for input events on the form. When the event comes from a field whose name is a key in state.answers, assign its value to that key. For example, editing the email field makes state.answers.email equal the field’s current value. On Next, save the active field’s value before changing steps, even if an input event would normally have done so. On Previous, decrease state.step and call render; do not clear or recreate answers. This makes backward navigation predictable and gives you one place to inspect the form’s current data.
Validate the step someone can see
Next should check the active step before advancing. With the two-field example, find the input in the visible section and call reportValidity() on it. A required name must not be empty, and an email input with type="email" receives the browser’s basic email-format check. Advance only when that call returns true. Browser validation is useful for immediate feedback, but it is not a substitute for validation wherever submitted data is ultimately processed.
On the final step, handle the form’s submit event rather than treating Next as submission. If this is a client-side demonstration, call preventDefault() and display a confirmation based on state.answers; do not imply that the data was sent anywhere. In an application that sends data, the submission behavior must be implemented separately. Avoid validating an unseen future step when someone clicks Next. If you add conditional questions later, determine which fields belong to the active step rather than assuming every input in the form is currently relevant.
Make movement understandable and accessible
Each input needs a visible label, and each step needs a heading that describes its purpose, such as “Your name” or “Contact details.” Show progress in words, such as “Step 1 of 2,” rather than relying only on a visual indicator. The hidden property removes the inactive section from ordinary display and keyboard navigation. Keep Previous available on the second step, and never reset a field merely because someone returns to it.
After a successful step change, move focus to the new step’s heading or first input so keyboard and screen-reader users have a clear starting point. If you focus a heading, give it a tabindex of -1 to make programmatic focus possible without adding it to the normal tab order. Do not move focus when validation fails; let the browser direct attention to the invalid field. When someone returns to an earlier step, their prior answer should be present and editable.
Choose deliberately whether reloads keep answers
In-memory state preserves answers while someone moves between steps, but a reload starts the page again. If reload recovery is appropriate, sessionStorage can hold a draft for the current tab. After an edit, serialize state.answers with JSON.stringify; at startup, parse the saved value and copy only the expected string fields into a fresh state object. Wrap storage reads, writes, and parsing in error handling because storage may be unavailable or its contents may not match your expected shape. Then call render to fill the inputs.
Do not store passwords, payment details, or other sensitive answers as a convenience feature. Even ordinary draft answers may be visible to someone using the same browser session, so consider whether persistence fits the form and provide a way to clear it. Test the component by entering both answers, going back and forward, changing the name, submitting, and reloading if draft storage is enabled. Also try an empty name, an invalid email, and keyboard-only navigation. Each test checks a specific promise: answers survive movement, errors block the right transition, and focus follows the visible step.



