T
TanStack2mo ago
harsh-harlequin

Is there a way to get the type of a form and form state from form options?

I'm trying to create a generic stepper form where certain parts are only visible if other parts are complete or have certain answers. Currently I'm doing this with nested <form.Subscribe> components, but it's pretty ugly. I'm hoping to do something like this:
export type Step<FormOpts, StepId extends string> = {
id: StepId;
dependencies?: StepId[];
shouldRender: (state: FormState<FormOpts>) => boolean;
render: (props: { form: Form<FormOpts> }) => React.ReactNode;
};

function shouldRenderStep<FormOpts, StepId extends string>(
step: Step<FormOpts, StepId>,
state: FormState<FormOpts>,
allSteps: Step<FormOpts, StepId>[]
): boolean {
if (!step.shouldRender(state)) {
return false;
}

const dependencies = step.dependencies ?? [];

for (const depId of dependencies) {
const dependentStep = allSteps.find((s) => s.id === depId);

if (!dependentStep) {
throw new Error(`Dependency step with id "${depId}" not found.`);
}

if (!dependentStep.shouldRender(state)) {
return false;
}
}

return true;
}

export function StepperForm<FormOpts, StepId extends string>({
form,
steps,
}: {
form: Form<FormOpts>;
steps: Step<FormOpts, StepId>[];
}) {
return (
<>
{steps.map((step) => (
<form.Subscribe
selector={(state) => shouldRenderStep(step, state, steps)}
key={step.id}
>
{(shouldRender) => shouldRender && <step.render form={form} />}
</form.Subscribe>
))}
</>
);
}
export type Step<FormOpts, StepId extends string> = {
id: StepId;
dependencies?: StepId[];
shouldRender: (state: FormState<FormOpts>) => boolean;
render: (props: { form: Form<FormOpts> }) => React.ReactNode;
};

function shouldRenderStep<FormOpts, StepId extends string>(
step: Step<FormOpts, StepId>,
state: FormState<FormOpts>,
allSteps: Step<FormOpts, StepId>[]
): boolean {
if (!step.shouldRender(state)) {
return false;
}

const dependencies = step.dependencies ?? [];

for (const depId of dependencies) {
const dependentStep = allSteps.find((s) => s.id === depId);

if (!dependentStep) {
throw new Error(`Dependency step with id "${depId}" not found.`);
}

if (!dependentStep.shouldRender(state)) {
return false;
}
}

return true;
}

export function StepperForm<FormOpts, StepId extends string>({
form,
steps,
}: {
form: Form<FormOpts>;
steps: Step<FormOpts, StepId>[];
}) {
return (
<>
{steps.map((step) => (
<form.Subscribe
selector={(state) => shouldRenderStep(step, state, steps)}
key={step.id}
>
{(shouldRender) => shouldRender && <step.render form={form} />}
</form.Subscribe>
))}
</>
);
}
But I'm not sure how to type Form and FormState. It looks like it's possible by kind of duplicatign the type of createFormHooks, but it ends up with like 9 generics and it's just nasty. I'm wondering if there's a better built-in way to do this (some type that gives the return type of useAppForm given the form options?) or if I'm going about this all wrong and should be taking a different approach.
13 Replies
graceful-blue
graceful-blue2mo ago
Stepped forms are not a feature yet, but it may be in the future ( https://github.com/TanStack/form/issues/419 ) For now, I recommend using one form for each step instead of grouping all steps into one. The reason is that form validators would consider all fields, even ones that haven‘t „rendered“ yet
GitHub
[Feature Request]: Form Groups · Issue #419 · TanStack/form
Description When building a form stepper, like so: It&#39;s common for each step to have its own form. However, this complicates the form submission and validation process by requiring you to add c...
harsh-harlequin
harsh-harlequinOP2mo ago
Interesting. Multiple forms would work, I'm just not sure how I'd handle submissions. Like how to trigger the submit validations on all the sub-forms when the final "submit" butotn gets clicked, how to bubble up the form data, etc
graceful-blue
graceful-blue2mo ago
oh, so you only have validation at the very end? Apologies, I assumed you validate on every step one form can work for that if you have it on every step, you would store the successful steps as values to ensure users don‘t change them afterwards
harsh-harlequin
harsh-harlequinOP2mo ago
I see, so if I validated at each step, it could sort of be like a "wizard" flow where the "next step" button actually submits the form, which then validates the data and passes the validated data up to the parent component via a callback prop?
graceful-blue
graceful-blue2mo ago
yes! but if instead the step button changes a field value and there‘s one submission, then one form‘s the way to go
harsh-harlequin
harsh-harlequinOP2mo ago
I gotcha. Stepper wasn't the right word for the UI I'm going for, it's more like a form where a section doesn't show unless the previous section has been answered and is in a certain state. For example, the first question is something like Is this an emergency? If the user selects "no", we show a contact information form. The last question in the contact info form is like "are you willing to answer some questions here or would you rather us call you", and if they select "yes" for that one it shows more questions. So they aren't really discrete steps that are shown in a wizard, more like a form that continuously updates as you answer more of it. And ideally I'd like to be able to do it without having the entire form component rerender every time any of that state changes, though that may not be possible right now.
graceful-blue
graceful-blue2mo ago
the form hook itself is stable, and if you subscribe to the step value only then the rerender would only happen within that section but yes, it‘s not possible right now. If you have API ideas for it, let us know!
harsh-harlequin
harsh-harlequinOP2mo ago
The main thing I had in mind is exposing a FormType<typeof useAppForm, typeof formOptions> generic that gives you the type of the return value of useAppForm(formOptions). I think it's possible to implement right now by doing something like this:
type InferUseAppFormReturn<T> =
T extends FormOptions<
infer TFormData,
infer TOnMount,
infer TOnChange,
infer TOnChangeAsync,
infer TOnBlur,
infer TOnBlurAsync,
infer TOnSubmit,
infer TOnSubmitAsync,
infer TOnServer,
infer TSubmitMeta
>
? AppFieldExtendedReactFormApi<
TFormData,
TOnMount,
TOnChange,
TOnChangeAsync,
TOnBlur,
TOnBlurAsync,
TOnSubmit,
TOnSubmitAsync,
TOnServer,
TSubmitMeta,
TComponents,
TFormComponents
>
: never;
type InferUseAppFormReturn<T> =
T extends FormOptions<
infer TFormData,
infer TOnMount,
infer TOnChange,
infer TOnChangeAsync,
infer TOnBlur,
infer TOnBlurAsync,
infer TOnSubmit,
infer TOnSubmitAsync,
infer TOnServer,
infer TSubmitMeta
>
? AppFieldExtendedReactFormApi<
TFormData,
TOnMount,
TOnChange,
TOnChangeAsync,
TOnBlur,
TOnBlurAsync,
TOnSubmit,
TOnSubmitAsync,
TOnServer,
TSubmitMeta,
TComponents,
TFormComponents
>
: never;
Plus the addition of taking a second generic (typeof useAppForm) and inerring TComponents and TFormComponents, but it's pretty gnarly. Plus AppFieldExtendedReactFormApi is not exported and I'm not sure if it's subject to change. But basically it would allow creating more generic components that are still typesafe with the specific form you're using them on. For example, right now I have a RenderIfTrue component that looks like this:
import { withForm } from '@/components/app-form';
import { patientFormDefaultValues, PatientFormSchema } from './form-options';

const RenderIfTrue = withForm({
defaultValues: patientFormDefaultValues,
props: {
children: null as React.ReactNode,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
getVal: ((values: PatientFormSchema) => false) as (
values: PatientFormSchema
) => unknown,
},
render: function Render({ form, children, getVal: selector }) {
return (
<form.Subscribe selector={(state) => selector(state.values)}>
{(val) => {
if (val !== true) return null;
return children;
}}
</form.Subscribe>
);
},
});

export default RenderIfTrue;
import { withForm } from '@/components/app-form';
import { patientFormDefaultValues, PatientFormSchema } from './form-options';

const RenderIfTrue = withForm({
defaultValues: patientFormDefaultValues,
props: {
children: null as React.ReactNode,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
getVal: ((values: PatientFormSchema) => false) as (
values: PatientFormSchema
) => unknown,
},
render: function Render({ form, children, getVal: selector }) {
return (
<form.Subscribe selector={(state) => selector(state.values)}>
{(val) => {
if (val !== true) return null;
return children;
}}
</form.Subscribe>
);
},
});

export default RenderIfTrue;
This works but I'd have to implement it for each individual form in my app. If it was easy to get the form type and form state type using the form options, the component could be made to work with any form, something like this:
function RenderIfTrue<FormOptions>({form, getVal, children}: {form: FormType<FormOptions, typeof useAppForm>, getVal: (values: FormState<FormOptions>) => boolean, children: React.ReactNode}) {
return (
<form.Subscribe selector={(state) => selector(state.values)}>
{(val) => {
if (val !== true) return null;
return children;
}}
</form.Subscribe>
);
}
function RenderIfTrue<FormOptions>({form, getVal, children}: {form: FormType<FormOptions, typeof useAppForm>, getVal: (values: FormState<FormOptions>) => boolean, children: React.ReactNode}) {
return (
<form.Subscribe selector={(state) => selector(state.values)}>
{(val) => {
if (val !== true) return null;
return children;
}}
</form.Subscribe>
);
}
But I might just be thinking about how the works wrong on a conceptual level
graceful-blue
graceful-blue2mo ago
don't worry about the typing and implementation. I meant what would an ideal scenario look like for you
harsh-harlequin
harsh-harlequinOP2mo ago
Ideally I'd be able to something like this:
import { type AppForm, type FormOptions, type UseAppFormHook } from '@tanstack/react-form';

export default function SomeFormComponent<AppFormHook extends UseAppFormHook, FormOpts extends FormOptions>({form}: {form: AppForm<AppFormHook, FormOpts>}) {
// typesafe stuff with the form here
}
import { type AppForm, type FormOptions, type UseAppFormHook } from '@tanstack/react-form';

export default function SomeFormComponent<AppFormHook extends UseAppFormHook, FormOpts extends FormOptions>({form}: {form: AppForm<AppFormHook, FormOpts>}) {
// typesafe stuff with the form here
}
Just being able to pass any form in the standard way as a component prop, and have the component be typesafe
graceful-blue
graceful-blue2mo ago
The goal is to purposefully not have to do that. See the philosophy section https://tanstack.com/form/latest/docs/philosophy
Philosophy | TanStack Form Docs
Every well-established project should have a philosophy that guides its development. Without a core philosophy, development can languish in endless decision-making and have weaker APIs as a result. Th...
harsh-harlequin
harsh-harlequinOP2mo ago
The part about not having to specify generics right? That makes sense, but in the case of creating generic form functions, the generics would be automatically inferred when you use the component just based on what you pass as the form prop no? And without something like that, is there a way to create generic components that can be used with any form in the app in a typesafe way? I've gotten myself into generic hell with react-hook-form, so I appreciate the philosophy, and if there's another way to do something like this I'm all ears.
graceful-blue
graceful-blue2mo ago
well, you have some helper types. AnyFieldApi and AnyFormApi

Did you find this page helpful?