It will take special handling in React Router to meet your security requirements. I personally would load the multi-step wizard on one URL rather than changing the URL for each step as this simplifies things and avoids a lot of potential issues. You can get the setup that you want, but it is much more difficult than it needs to be.
Path-Based Routing
I am using the new React Router v6 alpha for this answer, as it makes nested routes much easier. I am using /signup
as the path to our form and URLs like /signup/password
for the individual steps.
Your main app routing might look something like this:
import { Suspense, lazy } from "react";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import Header from "./Header";
import Footer from "./Footer";
const Home = lazy(() => import("./Home"));
const MultiStepForm = lazy(() => import("./MultiStepForm/index"));
export default function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<BrowserRouter>
<Header />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/signup/*" element={<MultiStepForm/>} />
</Routes>
<Footer />
</BrowserRouter>
</Suspense>
);
}
You'll handle the individual step paths inside the MultiStepForm
component. You can share certain parts of the form across all steps. The part which is your Routes
should just be the part that is different, ie. the form fields.
Your nested Routes
object inside the MultiStepForm
is essentially this:
<Routes>
<Route path="/" element={<Mobile />} />
<Route path="username" element={<Username />} />
<Route path="password" element={<Password />} />
</Routes>
But we are going to need to know the order of our route paths in order to handle "Previous" and "Next" links. So in my opinion it makes more sense to generate the routes based on a configuration array. In React Router v5 you would pass your config as props to a <Route/>
. In v6 you can skip that step and use object-based routing.
import React, { lazy } from "react";
const Mobile = lazy(() => import("./Mobile"));
const Username = lazy(() => import("./Username"));
const Password = lazy(() => import("./Password"));
/**
* The steps in the correct order.
* Contains the slug for the URL and the render component.
*/
export const stepOrder = [
{
path: "",
element: <Mobile />
},
{
path: "username",
element: <Username />
},
{
path: "password",
element: <Password />
}
];
// derived path order is just a helper
const pathOrder = stepOrder.map((o) => o.path);
Note that these components are called with no props. I am assuming that they get all of the information that they need through contexts. If you want to pass down props then you will need to refactor this.
import { useRoutes } from "react-router-dom";
import { stepOrder } from "./order";
import Progress from "./Progress";
export default function MultiStepForm() {
const stepElement = useRoutes(stepOrder);
return (
<div>
<Progress />
{stepElement}
</div>
);
}
Current Position
This is the part where things start to become convoluted. It seems that useRouteMatch
has been removed in v6 (for now at least).
We can access the matched wildcard portion on the URL using the "*"
property on the useParams
hook. But this feels like it might be a bug rather than an intentional behavior, so I'm concerned that it could change in a future release. Keep that in mind. But it does work currently.
We can do this inside of a custom hook so that we can derive other useful information.
export const useCurrentPosition = () => {
// access slug from the URL and find its step number
const urlSlug = useParams()["*"]?.toLowerCase();
// note: will be -1 if slug is invalid, so replace with 0
const index = urlSlug ? pathOrder.indexOf(urlSlug) || 0 : 0;
const slug = pathOrder[index];
// prev and next might be undefined, depending on the index
const previousSlug = pathOrder[index - 1];
const nextSlug = pathOrder[index + 1];
return {
slug,
index,
isFirst: previousSlug === undefined,
isLast: nextSlug === undefined,
previousSlug,
nextSlug
};
};
Next Step
The user must not be able to move between pages until he filled each request in each component.
You will need some sort of form validation. You could wait to validate until the user clicks the "Next" button, but most modern websites choose to validate the data every time that the form changes. Packages like Formik and Yup are a huge help with this. Check out the examples in the Formik Validation docs.
You will have an isValid
boolean which tells you when the user is allowed to move on. You can use that to set the disabled
prop on the "Next" button. That button should have type="submit"
so that its clicks can be handled by the onSubmit
action of the form.
We can make that logic into a PrevNextLinks
component which we can use in each form. This component uses the formik context so it must be rendered inside of a <Formik/>
form.
We can use the info from our useCurrentPosition
hook to render a Link
to the previous step.
import { useFormikContext } from "formik";
import { Link } from "react-router-dom";
import { useCurrentPosition } from "./order";
/**
* Needs to be rendered inside of a Formik component.
*/
export default function PrevNextLinks() {
const { isValid } = useFormikContext();
const { isFirst, isLast, previousSlug } = useCurrentPosition();
return (
<div>
{/* button links to the previous step, if exists */}
{isFirst || (
<Link to={`/form/${previousSlug}`}>
<button>Previous</button>
</Link>
)}
{/* button to next step -- submit action on the form handles the action */}
<button type="submit" disabled={!isValid}>
{isLast ? "Submit" : "Next"}
</button>
</div>
);
}
Here's an example of how one step might look:
import { Formik, Form, Field, ErrorMessage } from "formik";
import React from "react";
import { useDispatch } from "react-redux";
import { useNavigate } from "react-router";
import Yup from "yup";
import "yup-phone";
import PrevNextLinks from "./PrevNextLinks";
import { useCurrentPosition } from "./order";
import { saveStep } from "../../store/slice";
const MobileSchema = Yup.object().shape({
number: Yup.string()
.min(10)
.phone("US", true)
.required("A valid US phone number is required")
});
export default function MobileForm() {
const { index, nextSlug, isLast } = useCurrentPosition();
const dispatch = useDispatch();
const navigate = useNavigate();
return (
<div>
<h1>Signup</h1>
<Formik
initialValues={{
number: ""
}}
validationSchema={MobileSchema}
validateOnMount={true}
onSubmit={(values) => {
// I'm iffy on this part. The dispatch and the navigate will occur simoultaneously,
// so you should not assume that the dispatch is finished before the target page is loaded.
dispatch(saveStep({ values, index }));
navigate(isLast ? "/" : `/signup/${nextSlug}`);
}}
>
{({ errors, touched }) => (
<Form>
<label>
Mobile Number
<Field name="number" type="tel" />
</label>
<ErrorMessage name="number" />
<PrevNextLinks />
</Form>
)}
</Formik>
</div>
);
}
Preventing Access via URL
The user must not be able to switch components manually through the URL.
We need to redirect the user if they attempt to access a page which they are not permitted to view. The Redirects (Auth) example in the docs should give you some ideas on how this is implemented. This PrivateRoute
component in particular:
// A wrapper for <Route> that redirects to the login
// screen if you're not yet authenticated.
function PrivateRoute({ children, ...rest }) {
let auth = useAuth();
return (
<Route
{...rest}
render={({ location }) =>
auth.user ? (
children
) : (
<Redirect
to={{
pathname: "/login",
state: { from: location }
}}
/>
)
}
/>
);
}
But what is your equivalent version of useAuth
?
Idea: Look at the Current Progress
We could allow the visitor to view their current step and any previously entered steps. We look to see if the user is allowed to view the step which they are attempting to access. If yes, we load that content. If no, you can redirect them to their correct step or to the first step.
You would need to know what progress has been completed. That information needs to exist somewhere higher-up in the chain like localStorage
, a parent component, Redux, a context provider, etc. Which you choose is up to you and there will be some differences. For example using localStorage
will persist a partially-completed form while the others will not.
Where you store is less important that What you store. We want to allow backwards navigation to previous steps and forwards navigation if going to a previously-visited step. So we need to know which steps we can access and which we can't. The order matters so we want some sort of array. We would figure out the maximum step which we are allowed to access and compare that to the requested step.
Your component might look like this:
import { useRoutes, Navigate } from "react-router-dom";
import { useSelector } from "../../store";
import { stepOrder, useCurrentPosition } from "./order";
import Progress from "./Progress";
export default function MultiStepForm() {
const stepElement = useRoutes(stepOrder);
// attempting to access step
const { index } = useCurrentPosition();
// check that we have data for all previous steps
const submittedStepData = useSelector((state) => state.multiStepForm);
const canAccess = submittedStepData.length >= index;
// redirect to first step
if (!canAccess) {
return <Navigate to="" replace />;
}
// or load the requested step
return (
<div>
<Progress />
{stepElement}
</div>
);
}
CodeSandbox Link. (Note: Most of the code in the three step forms can and should be combined).
This is all getting rather complicated, so let's try something simpler.
Idea: Require that the URL Be Accessed from a Previous/Next Link
We can use the state
property of a location to pass through some sort of information that lets us know that we've come from the correct place. Like {fromForm: true}
. Your MultiStepForm
can redirect all traffic that lacks this property to the first step.
const {state} = useLocation();
if ( ! state?.fromForm ) {
return <Navigate to="" replace state={{fromForm: true}}/>
}
You would make sure that all of your Link
and navigate
actions inside of the form are passing this state.
<Link to={`/signup/${previousSlug}`} state={{fromForm: true}}>
<button>Previous</button>
</Link>
navigate(`/signup/${nextSlug}`, {state: { fromForm: true} });
With No Path Change
After having written quite a lot of code and explanation about authenticating a path, I've realized that you haven't explicitly said that the path needs to change.
I just need to use react-router-dom
properties to navigate.
So you could make use of the state
property on the location object to control the current step. You pass the state
through your Link
and navigate
the same as above, but with an object like {step: 1}
instead of {fromForm: true}
.
<Link to="" replace state={{step: 2}}>Next</Link>
You can majorly simplify your code by doing this. Though we come back to a fundamental question of why. Why use React Router if the important information is a state? Why not just use a local component state (or Redux state) and call setState
when you click on the "Next" button?
Here's a good article with a fully-implemented code example using local state and the Material UI Stepper component:
When all these steps are completed the browser will switch to the home page.
There are two ways to handle your final redirect to the home page. You can conditionally render a <Navigate/>
component (<Redirect/>
in v5) or you can call navigate()
(history.push()
in v5) in response to a user action. Both versions are explained in detail in the React Router v6 Migration guide.