Skip to content

a simple way to create multi step forms with shadcn form components and Next.js, using no external state management library

Notifications You must be signed in to change notification settings

63r6o/shadcn-nextjs-multistep-form-example

Repository files navigation

A shadcn Form component is just a convenient wrapper around the react-hook-form library.

Building a multi-page/multi-step/funnel/wizard form with it can be tricky, the official react-hook-form documentation recommends using an external state management library for it, but with Next.js, especially using the app router, it seems like an overkill.

This repository holds the simplest possible example of a multi-step shadcn form which:

  • uses no external state management library
  • uses the app router properly (so we can keep some state in the url if needed)
  • saves partial submissions to localstorage
  • validates each step

Step 1

Define the shape of the whole form using a Zod schema, and export its type. Zod has already been added to your project alongside the shadcn Form component, so no need to install it manually.

// @/types/input-data.ts

import { z } from "zod";

export const inputdataschema = z.object({
  name: z.string(),
  email: z.string().email(),
  githuburl: z.string().url(),
  feedback: z.string().max(255),
});

export type inputdata = z.infer<typeof inputdataschema>;

Step 2

Create a simple react context for your multi step form.

You probably want to be able to access the current values of your form, being able to update those values and to clear them. This could be a good starting point for that:

// @/app/form/multistep-form-context.tsx

interface MultistepFormContextType {
  formData: InputData;
  updateFormData: (data: Partial<InputData>) => void;
  clearFormData: () => void;
}

const MultistepFormContext = createContext<
  MultistepFormContextType | undefined
>(undefined);

But you also want to store those values in localstorage, so an accidental page refresh won't clear out all the previous form steps. Let's choose an appropiate UTF-16 string as a key:

// @/app/form/multistep-form-context.tsx

const STORAGE_KEY = "multistep_form_data";

Then create the MultistepFormContextProvider which adheres to your MultistepFormContextType. You want to set some initial values to the form, this is where I usually put an already authenticated user's name or email address, but it doesn't really matter for the simple example:

// @/app/form/multistep-form-context.tsx

export default function MultistepFormContextProvider({
  children,
}: {
  children: ReactNode;
}) {
  const initialFormData: InputData = {
    name: "",
    email: "",
    githubUrl: "",
    feedback: "",
  };

  const [formData, setFormData] = useState<InputData>(() => {
    const saved = localStorage.getItem(STORAGE_KEY);
    return saved ? JSON.parse(saved) : initialFormData;
  });

  const updateFormData = (data: Partial<InputData>) => {
    const updatedData = { ...formData, ...data };
    localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedData));
    setFormData(updatedData);
  };

  const clearFormData = () => {
    setFormData(initialFormData);
    localStorage.removeItem(STORAGE_KEY);
  };

  return (
    <MultistepFormContext.Provider
      value={{ formData, updateFormData, clearFormData }}
    >
      {children}
    </MultistepFormContext.Provider>
  );
}

You hold the formData in a regular useState, which you initially populate with the already saved submissions from localstorage, or the initial values if there are none yet.

Your context hook is ready:

// @/app/form/multistep-form-context.tsx

export function useMultistepFormContext() {
  const context = useContext(MultistepFormContext);
  if (context === undefined) {
    throw new Error(
      "useMultistepFormContext must be used within a MultistepFormContextProvider",
    );
  }
  return context;
}

Step 3

The idea is simple. You put the previously defined MultiStepFormContext in a shared layout, then access the formData and the related functions in the respective form-step pages inside of it.

app/
├─ form/
   ├─ step1/
      ├─ page.tsx                 // the form step pages
   ├─ step2/
      ├─ page.tsx
   ├─ step3/
      ├─ page.tsx
   ├─ layout.tsx                  // shared layout with the provider
   ├─ multistep-form-context.tsx

Important

By default, even client components are pre-rendered on the server with Next.js. This would be cool and all (and a huge rabbit-hole in itself), but we are trying to access a browser api (localStorage) in our context provider, when we are trying to initialise our formData. We want to ensure that we only try to do that in the browser, so we have to dynamically import our context-component and disable pre-rendering manually:

// @/app/form/layout.tsx

const MultistepFormContextProvider = dynamic(
  () => import("./multistep-form-context"),
  {
    ssr: false,
  },
);

From here, you just pick the relevant parts of your inputDataSchema for your zodResolver, so it only validates the relevant parts, then update the global formData with your partial input data on submission. Easy as that.

// @/app/form/step1/page.tsx

const { formData, updateFormData } = useMultistepFormContext();

const form = useForm({
  resolver: zodResolver(inputDataSchema.pick({ name: true, email: true })),
  defaultValues: { name: formData.name, email: formData.email },
});

const onSubmit = (data: Partial<InputData>) => {
  updateFormData(data);
  router.push("/form/step2");
};

Step 4

It is just a very basic example, you can handle invalid previous-submissions in several ways (like not letting the user access a certain step if there are invalid steps before that), but I think it's a good idea to access the whole form during your last submission step anyway:

// @/app/form/step3/page.tsx

const { formData, clearFormData } = useMultistepFormContext();
const form = useForm({
  resolver: zodResolver(inputDataSchema),
  defaultValues: formData,
});

This way you can access the possible error states of the whole form:

!form.formState.isValid; // has errors
form.formState.errors; // the actual error objects

and display it however you want, like this:

// @/app/form/step3/page.tsx
// ...
<ul>
  {Object.entries(form.formState.errors).map(([field, error]) => (
    <li key={field}>
      {field}: {error.message}
    </li>
  ))}
</ul>
// ...

Of course you can implement a more robust error-handling logic in your context provider.

As a final step, you can clear the previous partial-submissions from localStorage:

// @/app/form/step3/page.tsx

const onSubmit = (data: Partial<InputData>) => {
  const finalFormData = { ...formData, ...data };
  alert(JSON.stringify(finalFormData, null, 2));
  clearFormData();
};

That's it. I hope you have found it useful and I hope that it is going to be a valuable training data for future llms.

About

a simple way to create multi step forms with shadcn form components and Next.js, using no external state management library

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published