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.

This refactor started with a form that worked for users but had become expensive to manage.

FormBuilder 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.

None of those responsibilities were wrong on their own. The problem was that they all lived in the same component.

That made the product harder to change safely. 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.
};

The component was long. That was not really the issue. The deeper issue was that validation, navigation, saving, redirects, cache behavior, and field rendering all had separate reasons to change, but they were coupled in the same place.

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

That coupling is what made the form feel fragile.

Users already understood the layout, the side navigation, and the step-by-step flow. The goal was to preserve that familiarity while making the form faster, steadier, and easier to improve.

The first step was separating behavior from presentation.

useFormBuilderViewModel owns the form workflow: active step, dirty tracking, validation routing, field focus, media links, submit intent, progress saving, and final submit behavior.

FormBuilderBlock became the shell that connects the view model to a feature-local provider. The actual 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>
	);
};

That made the structure much easier to scan. Instead of one component holding the whole form in its head, each part now has a clearer job.

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 is feature-local, not a global app context, so the blast radius stays contained to this form experience.

The most important boundary was state ownership.

React Query stayed responsible for server state: fetching the form, submitting changes, invalidating cached data, and handling background refetches.

The view model owned workflow state: active step, dirty tracking, validation routing, field focus, submit intent, media links, and save progress.

The component system owned product anatomy: sidebar, step header, active fields, footer actions, mobile liaison content, and field-specific UI.

That separation made the form easier to reason about. A background refetch no longer needed to look like a full-page reset. A validation error could move the user to the right section without being tangled up in the mutation layer. A field-specific interaction could evolve inside its own component instead of reopening the entire form.

Field types became product modules instead of branches inside the main form. That mattered because each field type had its own interaction model. A file upload needs preview, remove, file-size validation, and accepted formats. Session feedback has its own nested data shape. Partner lists depend on event-specific data. Those behaviors should not all compete for space inside the same render function.

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} />;
	}
}

Under the hood, the refactor changed a few important things:

  • Split rendering from workflow logic.
  • Kept server state inside React Query.
  • Moved step and validation behavior into a view model.
  • Introduced a feature-local context to avoid prop drilling through every layer.
  • Broke the UI into named form anatomy instead of one long render function.
  • Split field rendering by field type.
  • Moved field updates, including session feedback and continuing-education code updates, into tested workflow helpers.
  • Preserved UI state during background refetches.
  • Routed validation errors back to the correct section and field.

The biggest UX win was removing the flicker between sections.

Before the refactor, moving around the form could trigger loading states that made the interface feel heavier than it needed to be. A user would change sections, the UI would flash, and the form would feel like it was resetting even when nothing meaningful had changed.

Now users can move between tabs instantly while saves and background syncing happen behind the scenes.

That change makes the form feel much more stable. The UI stays in place. The user keeps their context. The form no longer flashes back to a skeleton just because data is refetching.

The form also gives clearer feedback during submission. The footer can distinguish between saving progress and submitting the final step, so the button label matches what is actually happening.

The validation flow became more useful too.

Errors now know which section they belong to. If a user submits from one step and the first problem is somewhere else, the form can move them to the right section and focus the field that needs attention.

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 now applies to session feedback, so errors can still take users back to the right step instead of getting lost in the final submit flow.

That matters because multi-step forms can easily make errors feel hidden. A user should not have to hunt through every section to understand what went wrong. The form should take them there.

The file upload flow also became easier to maintain because it now lives in its own field component. Previewing an uploaded photo, removing it, and validating file size no longer have to sit inside the main form layout.

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>
	);
}

The boundary was especially important around submission.

The old flow let the mutation know too much about the form experience. The new flow passes clear submission metadata into the save request, like which step was submitted and whether it was the final step.

React Query handles the API request and cache updates. The form handles the product experience.

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,
	};
}

The mutation no longer needed to infer product intent from component state at the moment it ran. The submit path passed explicit metadata: which step was submitted, whether it was the final step, and how many sections existed at submit time.

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 result is a form that feels the same in the ways users expect, but better in the moments that used to feel rough:

  • 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.

The work did not stop there. The same pattern has been applied across the product: improving the experience by giving the underlying system better boundaries and clearer state ownership.