Skip to content

Abstract onboarding wizard into reusable FormWizard#663

Open
SinhSinhAn wants to merge 7 commits into
developfrom
feature/588-abstract-formwizard
Open

Abstract onboarding wizard into reusable FormWizard#663
SinhSinhAn wants to merge 7 commits into
developfrom
feature/588-abstract-formwizard

Conversation

@SinhSinhAn

Copy link
Copy Markdown
Contributor

Summary

  • Extracted the onboarding multi-step wizard into reusable form.Wizard and form.WizardStep components that any form can use
  • Fixed a bug where validation blocked backward navigation (now only blocks forward)
  • OnboardingForm shrunk from ~400 lines to ~260 lines, with step content declared inline via <form.WizardStep>

How it works

The wizard registers as a TanStack Form formComponent, so consumers use it as form.Wizard / form.WizardStep:

<form.Wizard startStep={<Intro />} finishStep={<Done />} onComplete={() => router.push('/')}>
  <form.WizardStep label="Name" fields={['firstName', 'lastName']}>
    ...form fields...
  </form.WizardStep>
</form.Wizard>

Each step declares its fields array, which the wizard uses for per-step validation. The wizard handles the MUI Stepper, slide transitions, dynamic height, and navigation buttons automatically.

Files changed

File What
src/components/form/FormWizard/ New directory with types, context, Wizard, and WizardStep
src/utils/form.ts Registered Wizard + WizardStep in formComponents
src/components/getting-started/OnboardingForm.tsx Rewritten to use the new wizard
src/components/getting-started/OnboardingFormStep.tsx Deleted (content moved inline)
src/components/form/FormWizard.tsx Deleted (replaced by directory)

Closes #588

Extract the ~400-line OnboardingForm wizard into a reusable form.Wizard
and form.WizardStep component system that integrates with TanStack Form's
formComponents registry. This enables multi-step wizards for onboarding,
club match, club creation, and event creation forms.

Key changes:
- Create FormWizard/ directory with types, context, and components
- Register Wizard and WizardStep as formComponents in form.ts
- Fix validation to only block forward navigation (not backward)
- Auto-advance to finish step after successful form submission
- Rewrite OnboardingForm from ~400 lines to ~260 lines
- Delete OnboardingFormStep (content moved inline to WizardStep children)

Closes #588
@vercel

vercel Bot commented Mar 31, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
clubs Ready Ready Preview, Comment Jun 20, 2026 4:17am

Request Review

@SinhSinhAn

Copy link
Copy Markdown
Contributor Author

@Isoscelestial, Hey Isaac wanted to follow up on this, can you check it when you get the chance?

@Isoscelestial Isoscelestial left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did a quick look through, I like how it's looking so far! After you fix these things, I'll do another more in-depth review.

Comment on lines +3 to +20
type WizardStepObjectBase = {
label: string;
hidden?: boolean;
};

export type WizardStepObject<Fields> = WizardStepObjectBase &
(
| {
id: string | number;
variant?: 'body';
fields: (keyof Fields)[];
}
| {
id?: never;
variant: 'start' | 'finish';
fields?: never;
}
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You replaced these (now unused) props with StepConfig. However, these unused props had type safety for fields and ensuring the start and finish variants don't have fields. Could you try to use WizardStepObject again, to add type safety?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Never mind, you can remove these unused types now

Comment thread src/components/form/FormWizard/FormWizard.tsx
Comment thread src/components/getting-started/OnboardingForm.tsx Outdated
Comment thread src/components/form/FormWizard/FormWizard.tsx Outdated
Co-authored-by: Isoscelestial <isoscelestial@gmail.com>
Moved startStep and finishStep from Wizard props into WizardStep children using startStep and finishStep boolean props, so the API is consistent with how body steps are declared. Restored type safety on StepConfig using a discriminated union so start and finish variants cannot have fields. Fixed the Continue button on the finish screen not working, and made the wizard wait for the API call to finish before advancing to the finish step. Fixed date picker dark mode background from neutral-900 to neutral-800.

@Isoscelestial Isoscelestial left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good, love to see it functional! Sorry that I'm requesting a lot of changes...

Comment on lines +320 to +322
disabled={
index - 1 > activeStep.index || isOnFinishStep
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add an or condition of (!currentFieldsValid && index >= activeStep.index)? This disables the stepper and prevents users from continuing if there is an error

Suggested change
disabled={
index - 1 > activeStep.index || isOnFinishStep
}
disabled={
index - 1 > activeStep.index ||
isOnFinishStep ||
(!currentFieldsValid && index >= activeStep.index)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would also be nice to update the condition index - 1 > activeStep.index so that instead of using activeStep.index, it uses another variable called completedSteps that keeps track of what steps a user has completed. In other words, completedSteps is incremented whenever activeStep is incremented, but it never gets decremented (it will reset when the page reloads). That way, when a user goes back, they can immediately jump back forward, but the stepper still prevents users from jumping to the end.

Comment on lines +255 to +258
setActiveStep({
index: steps.length - 1,
previous: activeStep.index,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For reference, this is technically bad practice because it could lead to race conditions (you're using a state in its own setter). You don't have to fix it here since I'm about to suggest a change that will replace it, but this would be the preferred way:

      setActiveStep((prev) => ({
        index: prev.index - 1,
        previous: prev.index,
      }));

const shouldAutoAdvance = autoAdvanceOnSubmit ?? hasFinish;

// Step navigation state
const [activeStep, setActiveStep] = useState<ActiveStep>({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a lot of repeated code in this file since activeStep.previous is always set to the previous value of activeStep.index. Can you add a helper function that sets active step that handles setting activeStep.previous automatically, or abstract this into a usePrevious hook? Make sure to use a functional update for setting the state to avoid race conditions, as I mentioned in my previous comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we also rename this state to something like actionStepState? Then we can do this:

const [activeStepState, setActiveStepState] = useState<ActiveStep>({ index: 0, previous: undefined});

const activeStep = activeStepState.index
const previousStep = activeStepState.previous

Then update all the calls to activeStep.index to activeStep, and activeStep.previous to previousStep


const hasStart = steps[0]?.variant === 'start';
const hasFinish = steps[steps.length - 1]?.variant === 'finish';
const shouldAutoAdvance = autoAdvanceOnSubmit ?? hasFinish;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpicking, but this variable name is imprecise. It implies that the form auto advances on every step, but it only auto advances on submission. Probably rename to shouldAutoAdvanceOnSubmit (or instead add a comment explaining this)

Comment on lines +36 to +49
* ```tsx
* <form.Wizard onComplete={() => router.push('/')}>
* <form.WizardStep startStep>
* <Intro />
* </form.WizardStep>
* <form.WizardStep label="Name" fields={['firstName', 'lastName']}>
* ...form fields...
* </form.WizardStep>
* <form.WizardStep finishStep>
* <Done />
* </form.WizardStep>
* </form.Wizard>
* ```
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpicking again (sorry 😭), but use the @example tag from JSDoc. Also tweaked the example a bit

Suggested change
* ```tsx
* <form.Wizard onComplete={() => router.push('/')}>
* <form.WizardStep startStep>
* <Intro />
* </form.WizardStep>
* <form.WizardStep label="Name" fields={['firstName', 'lastName']}>
* ...form fields...
* </form.WizardStep>
* <form.WizardStep finishStep>
* <Done />
* </form.WizardStep>
* </form.Wizard>
* ```
*/
* @example
* const form = useAppForm({...})
*
* <form.Wizard onComplete={() => router.push('/')}>
* <form.WizardStep startStep>
* ...
* </form.WizardStep>
* <form.WizardStep label="Name" fields={['firstName', 'lastName']}>
* ...form fields...
* </form.WizardStep>
* <form.WizardStep finishStep>
* ...
* </form.WizardStep>
* </form.Wizard>
*/

@@ -121,280 +76,193 @@ export default function OnboardingForm({
validators: { onChange: accountOnboardingSchema },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Change this to use the onSubmit validation event instead. This serves as a last-resort for all of the other validations that I'm assuming you implemented from my previous suggestion.

Suggested change
validators: { onChange: accountOnboardingSchema },
validators: { onSubmit: accountOnboardingSchema },

To explain why I said earlier we can't use the onSubmit validation event for fields is because that'd mean we'd need to use form.validateField(field, 'submit'); And annoyingly, this will call this form-level onSubmit validator that we just added here (there's a logical reason for this, but it's a headache). So it will validate all the fields before we want it to.

To sum up everything, TanStack Form is missing features we need. Fortunately, they just started working on it in TanStack/form#2128. Looking forward to this, but for now we'll use this workaround (we'd probably want to refactor FormWizard again whenever this feature is released)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't make this change until you've made the changes here: #663 (comment). Since you've already committed this particular change, you'll need to go ahead and work on that thread I linked.

}
}, [hasFinish, steps.length]);

const handleNext = (event: MouseEvent<HTMLButtonElement>) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shares a lot of duplicated code with the goNext function. Can you call goNext() here somehow?

Comment on lines +65 to +68
goNext: () => void;
goBack: () => void;
goToStep: (index: number) => void;
goToFinish: () => void;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be useful to return the new activeStep instead of void for goNext, goBack, and goToFinish? Not necessary, just thinking ahead

};

export type WizardContextType = {
activeStep: ActiveStep;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should split the activeStep object to activeStep: number, previousStep: number | undefined. In conjunction with last one of my earlier suggestions, we could then remove the unused ActionStep type

Comment on lines +3 to +20
type WizardStepObjectBase = {
label: string;
hidden?: boolean;
};

export type WizardStepObject<Fields> = WizardStepObjectBase &
(
| {
id: string | number;
variant?: 'body';
fields: (keyof Fields)[];
}
| {
id?: never;
variant: 'start' | 'finish';
fields?: never;
}
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Never mind, you can remove these unused types now

@TyHil

TyHil commented Jun 10, 2026

Copy link
Copy Markdown
Member

Adding a note here to copy these changes over to Notebook after merge.

@SinhSinhAn

Copy link
Copy Markdown
Contributor Author

Resolved all FormWizard-related threads since we're taking a different approach: fixing the two real bugs directly in OnboardingForm.tsx instead of extracting the full abstraction.

What's fixed (on fix/683-google-event-guard):

  1. Backward navigation no longer blocked by validation. Removed the validateFields() / currentFieldsValid() gate from handleBack and removed !currentFieldsValid() from the Back button's disabled prop.
  2. ResizeObserver leak. The callback ref's return value was silently ignored by React. Now stores the observer in a useRef and disconnects properly on re-attach.

Left unresolved: The thread about changing onChange to onSubmit validator timing is a separate concern worth discussing independently.

The FormWizard abstraction can be revisited when a second wizard consumer actually exists (club creation, event creation, etc.), at which point the real API surface will be clearer.

Co-authored-by: Isoscelestial <isoscyoung@gmail.com>
@Isoscelestial

Isoscelestial commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Resolved all FormWizard-related threads since we're taking a different approach: fixing the two real bugs directly in OnboardingForm.tsx instead of extracting the full abstraction.

You already made the full abstraction. At this point, we are just fixing bugs and code quality issues. I am unresolving all the threads so that they can be fixed. If you'd like help with some of the threads, I'd be more than happy to tackle some of them!

What's fixed (on fix/683-google-event-guard):

Not sure where this branch name came from. If your AI tends to hallucinate, I ask that you at the very least double check your code and comments before sending them to be reviewed.

The thread about changing onChange to onSubmit validator timing is a separate concern worth discussing independently.

I'd like these changes to be made now, as we've figured out a solution and are just waiting for the code to be written.

Related PR: UTDNebula/utd-notebook#183

The FormWizard abstraction can be revisited when a second wizard consumer actually exists (club creation, event creation, etc.), at which point the real API surface will be clearer.

As we discussed earlier, we are planning to have a "second wizard consumer" by using this abstracted system with the club matching form, the club creation form, and event creation form.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Abstract onboarding experience to FormWizard

3 participants