Skip to main content

Untangling a Multi-Step Form Component

TL;DR: I replaced a 1,700-line multi-step form component with a compound component structure backed by a view model. The new form keeps the existing user flow, but removes flicker between sections, preserves draft state during background saves, and makes validation section-aware.

The FormBuilder component had slowly turned into the place for everything: rendering, validation, step navigation, field updates, file upload behavior, mutation callbacks, redirects, cache updates, loading states, and one-off product cases.

A small UX improvement could touch validation, mutation state, navigation, and rendering at the same time. The form still made sense to users, but the implementation was starting to make every change feel heavier than it needed to be.

// BEFORE
const FormBuilderForm = ({
	formData,
	continueButtonLabel,
	formSuccessMessage,
	finalStepButtonLabel,
	campaignId,
	formType,
	eventTitle,
	redirectPath,
	successFunction,
	isRefetching,
}: FormBuilderFormProps) => {
	const [activeStep, setActiveStep] = useState(0);
	const [workingFormData, setWorkingFormData] = useState(formData);
	const [formErrors, setFormErrors] = useState<FormError[]>([]);
	const [mediaLinks, setMediaLinks] = useState<MediaLink[]>([]);
	const [isLastStep, setIsLastStep] = useState(false);
	const fieldRefs = useRef<{ [key: string]: HTMLElement | null }>({});

	const submitFormMutation = useSubmitFormBuilderMutation({
		activeStep,
		campaignId,
		formData,
		formType,
		isLastStep,
		setActiveStep,
		setIsLastStep,
		setShowNav,
		workingFormData,
		onError,
		onFinalSuccess,
	});

	// UI rendering, validation, field updates, navigation,
	// mutation callbacks, redirects, and special cases all lived here.
};

Changing the footer button state meant understanding the mutation flow. Improving validation meant tracing step navigation. Adjusting a field type meant reopening the main form component. Fixing refetch flicker meant following state across React Query, local form data, and loading UI.

I didn't want to change the layout or user flow, so I kept that structure and separated the behavior from the presentation.

useFormBuilderViewModel now owns the workflow: active step, dirty tracking, validation, field focus, media links, save progress, and final submission.

FormBuilderBlock connects that view model to a feature-local provider, while the screen is composed from named pieces: sidebar, main content, step header, fields, footer, and mobile liaison content.

// AFTER
const FormBuilderBlock = (props: FormBuilderFormProps) => {
	const form = useFormBuilderViewModel(props);

	if (!form.workingFormData) {
		return null;
	}

	return (
		<FormBuilderProvider
			value={{
				form,
				continueButtonLabel: props.continueButtonLabel,
				eventTitle: props.eventTitle,
				finalStepButtonLabel: props.finalStepButtonLabel,
				formType: props.formType,
				originalFormData: props.formData,
			}}
		>
			<FormBuilder.Root>
				<FormBuilder.Sidebar />
				<FormBuilder.Main>
					<FormBuilder.StepHeader />
					<FormBuilder.Form>
						<FormBuilder.Fields />
						<FormBuilder.Footer />
					</FormBuilder.Form>
					<FormBuilder.MobileLiaisons />
				</FormBuilder.Main>
			</FormBuilder.Root>
			<Toaster />
		</FormBuilderProvider>
	);
};

FormBuilder.Sidebar owns section navigation and liaison placement.

FormBuilder.StepHeader owns the current section title and description.

FormBuilder.Fields owns field rendering for the active step.

FormBuilder.Footer owns the continue, save, and submit button state.

The provider stays local to the feature. React Query handles server state and background refetches, while the view model owns the active step, dirty tracking, validation, field focus, media links, and save progress.

That split fixed a few recurring problems. Background refetches no longer reset the screen. Validation can move people to the right section without reaching into mutation logic. Field-specific behavior can stay inside the field that owns it.

Each field type also became its own component. File uploads need previews, removal, file-size validation, and accepted formats. Session feedback has nested data. Partner lists depend on event-specific data. Keeping those behaviors separate made the main form much easier to work in.

export function FormBuilderField({ field, fieldIndex }: FormBuilderFieldProps) {
	switch (field.type) {
		case "SESSION_FEEDBACK":
			return <FormBuilderSessionFeedbackField field={field} />;
		case "FILE":
			return <FormBuilderFileField field={field} />;
		case "SELECT":
			return <FormBuilderSelectField field={field} />;
		case "MULTISELECT":
			return <FormBuilderMultiSelectField field={field} />;
		case "TEXT_AREA":
			return <FormBuilderTextareaField field={field} />;
		default:
			return <FormBuilderTextField field={field} />;
	}
}

The refactor changed a few things under the hood:

  • UI state stays in place during background refetches.
  • Step and validation behavior lives in the view model.
  • Field updates moved into tested workflow helpers.
  • Validation errors can point back to the right section and field.

The biggest improvement was removing the flicker between sections.

Before the refactor, changing tabs could trigger a loading state that made the form look like it was resetting. Now tabs switch immediately while saves and background syncing continue behind the scenes.

The footer also knows whether the form is saving progress or submitting the final step, so the button state matches what is actually happening.

Validation got better too. Each error includes the section it belongs to. If the first invalid field is somewhere else, the form moves to that section and focuses the field automatically.

const prefersReducedMotion = window.matchMedia(
	"(prefers-reduced-motion: reduce)"
).matches;

const focusFirstError = (error: FormError) => {
	if (error.sectionIndex !== undefined && error.sectionIndex !== activeStep) {
		pendingFocusFieldRef.current = error.inputID;
		setActiveStep(error.sectionIndex);
		return;
	}

	const errorElement = fieldRefs.current[error.inputID];

	if (!errorElement) {
		return;
	}

	errorElement.scrollIntoView({
		behavior: prefersReducedMotion ? "auto" : "smooth",
		block: "center",
	});
	errorElement.focus();
};

That same section-aware validation also applies to session feedback, so errors can take users back to the right step during final submission.

In a long multi-step form, that keeps validation from becoming a scavenger hunt. The form can move the user directly to the field that needs attention.

File uploads are a good example of what the new field structure looks like in practice. Preview, validation, focus, and error handling all live inside FormBuilderFileField:

export function FormBuilderFileField({ field }: FormBuilderFileFieldProps) {
	const { fieldRefs, formErrors, handleChange, isFieldDisabled } = useFormBuilder();
	
	const fieldError = formErrors.find(
		(error) => error.inputID === field.apiName
	);

	const errorId = `${field.apiName}-error`;

	return (
		<div className="grid w-full max-w-full items-center gap-2">
			{field.value && (
				<NextImage
					src={getUploadedImageSrc(field.value)}
					alt="Uploaded photo preview"
					width={64}
					height={64}
					unoptimized
					className="size-16 rounded-full object-cover"
				/>
			)}

			<Input
				type="file"
				id={field.apiName}
				className="peer sr-only"
				disabled={isFieldDisabled}
				ref={(el) => {
					fieldRefs.current[field.apiName] = el;
				}}
				accept=".jpg,.jpeg,.png"
				aria-invalid={fieldError ? "true" : undefined}
				aria-describedby={fieldError ? errorId : undefined}
				onChange={async (event) => {
					const file = event.currentTarget.files?.[0];

					if (!file) {
						handleChange("", field.apiName);
						return;
					}
					
					const valueString = await getUploadedFileValue(file);

					handleChange(valueString, field.apiName);
				}}
			/>

			<label
				htmlFor={field.apiName}
				className={cn(
					buttonVariants({ variant: "outline" }), 
					"w-fit peer-focus-visible:ring-2 peer-focus-visible:ring-offset-2",
					isFieldDisabled && "pointer-events-none opacity-50"
				)}
			>
				<Upload className="mr-2 size-4" />
				Select a photo
			</label>

			{fieldError && (
				<div id={errorId} className="text-sm text-status-danger">
					Please upload a .jpg or .png file less than 1MB.
				</div>
			)}
		</div>
	);
}

Submission was another place where responsibilities had started to blur.

The old mutation had to infer too much from component state. I changed the submit path to pass that intent explicitly: which step was submitted, whether it was the final step, and how many sections existed at that point.

React Query handles the request and cache updates. The form handles what happens in the interface.

export function buildFormBuilderSubmitData({
	campaignId,
	formType,
	mediaLinks,
	sectionCount,
	submittedIsLastStep,
	submittedStep,
	workingFormData,
}: BuildSubmitDataOptions) {
	if (!campaignId) {
		throw new Error("Cannot submit form without a campaign ID.");
	}

	return {
		mediaLinks: getEnteredMediaLinks(mediaLinks),
		formData: workingFormData,
		campaignId,
		formType,
		submittedIsLastStep,
		submittedSectionCount: sectionCount,
		submittedStep,
	};
}

With that metadata in place, the mutation can choose the right submit path, preserve the current form state before the request, and restore it if the save fails.

export function useSubmitFormBuilderMutation({
	campaignId,
	formData,
	formType,
	workingFormData,
}: FormBuilderMutationOptions) {
	const queryClient = useQueryClient();
	const formQueryKey = qk.form(formType, getEventId(campaignId));

	return useMutation({
		mutationKey: [...formQueryKey, "submit"],
		mutationFn: async (data) => {
			const {
				submittedIsLastStep,
				submittedSectionCount: _submittedSectionCount,
				submittedStep: _submittedStep,
				...submitVariables
			} = data;

			const postData = {
				...submitVariables,
				lastStep: submittedIsLastStep,
			};

			const hasSessionFeedback =
				formData?.sessionFeedback?.sessions?.length > 0;

			if (hasSessionFeedback) {
				if (submittedIsLastStep) {
					return submitSessionFeedback({
						eventId: String(getEventId(campaignId)),
						body: workingFormData.sessionFeedback,
					});
				}

				const submitData = {
					...postData,
					formData: {
						...postData.formData,
						formSections: postData.formData.formSections.slice(0, -1),
					},
				};

				return submitFormRequest(submitData);
			}

			return submitFormRequest(postData);
		},
		onMutate: async (variables) => {
			await queryClient.cancelQueries({ queryKey: formQueryKey });
			const previousFormData = queryClient.getQueryData(formQueryKey);

			return {
				previousFormData,
				previousStep: variables.submittedStep,
			};
		},
		onError: (_error, _variables, context) => {
			if (context?.previousFormData) {
				queryClient.setQueryData(formQueryKey, context.previousFormData);
			}
		},
	});
}

The form now feels faster and more predictable without changing the flow users already knew:

  • Tabs switch instantly with no loading flicker.
  • Background saves do not interrupt the user.
  • Validation can guide people across sections.
  • Button states better match what the form is doing.
  • Field-specific interactions are easier to improve.
  • The UI is separated from workflow logic.
  • The legacy 1,700-line component is no longer the active path.

There are definitely tradeoffs. The form went from one file to a couple dozen, tracing behavior takes a few more hops, and the session feedback branch in the mutation is still a special case that needs polish. I'd make the same call again. The new structure makes day-to-day changes much cheaper, and for a form we touch constantly, that trade is worth it.

Since implementing this, I've used the same approach elsewhere in the product: making state ownership explicit, keeping behavior close to where it belongs, and letting the interface stay focused on the interaction.