3

I want to switch between components after the user entered the requested info.
Components that will be shown to user by this order:

  1. {MobileNum } Enter mobile number
  2. {IdNumber } ID number
  3. {CreatePassword } Create Password

When all these steps are completed the browser will switch to the home page. The user must not be able to move between pages until he filled each request in each component.

Now I want a better way with router as if I had 3-4 components inside Login, and must be in a secured whey, also the user must not be able to switch components manually through the URL.

import React, { Component } from 'react';
import {
  BrowserRouter as Router,
  Redirect,
  Route,
  Switch,
} from 'react-router-dom';
import MobileNum from './MobileNum.jsx';
import IdNumber from './IdNum.jsx';
import CreatePassword from './createPassword .jsx';

class SignUp extends Component {
  constructor(props) {
    super(props);
  }

  render() {
    return (
      <div>
        <Router>
          <Switch>
                //// Here needs to know how to navigate to each component on its turn
            <Route path='/'  component={MobileNum} />                                                
            <Route path='/'  component={IdNumber} />
            <Route path='/'  component={CreatePassword } />
          </Switch>
        </Router>
      </div>
    );
  }
}

export default SignUp ;

I searched the web in reactrouter.com and many others as here for a clean solution but found no answer.
Any Idea what's the best way to do it ?

Thanks

Anish Antony
  • 1,379
  • 8
  • 13
ExtraSun
  • 528
  • 2
  • 11
  • 31

3 Answers3

4

Since router variable like location are immutable, conditional rendering itself would be better option, you can try switch if you don't want to use if else. I have given an example below, you have to fire that afterSubmit when values are submitted in each component .If you use redux, you could implement it better as you can store the value in redux state and set it directly from each component using dipatch.

//App.js
import React, { useState } from 'react';
import MobileNum from './MobileNum.jsx';
import IdNumber from './IdNum.jsx';
import CreatePassword from './createPassword .jsx';

function App (){
 const [stage,setStage]= useState(1);
 switch(stage){
    case 2:
      return <IdNumber  afterSubmit={setStage.bind(null,3)}/>
      break;
    case 3:
      return <CreatePassword afterSubmit={setStage.bind(null,4)} />
    case 4:
      return <Home  />
      break;
    default:
      return <MobileNum  afterSubmit={setStage.bind(null,2)}/>
 }
}

export default App;

//Root

import React, { Component } from 'react';
import App from './App.jsx';
import {
  BrowserRouter as Router,
  Redirect,
  Route,
  Switch,
} from 'react-router-dom';

class Login extends Component {
  constructor(props) {
    super(props);
  }

  render() {
    return (
      <div>
        <Router>
          <Switch>
        <Route path='/'  component={App} />    
          </Switch>
        </Router>
      </div>
    );
  }
}

//Add on - Sign up form class based 
class SignUp extends React.Component {
  constructor(props) {
    super(props);
    this.state = { stage: 1 };
  }

  render() {
    switch (this.state.stage) {
      case 2:
        return <IdNumber afterSubmit={() => this.setState({ stage: 3 })} />;
        break;
      case 3:
        return <CreatePassword afterSubmit={() => this.setState({ stage: 4 })} />;
      case 4:
        return <Home />;
        break;
      default:
        return <MobileNum afterSubmit={() => this.setState({ stage: 2 })} />;
    }
  }
}
Anish Antony
  • 1,379
  • 8
  • 13
  • 1
    Hi Anish, I need to know in `setStage.bind(null,2)` what the `.bind(null,number)` means ? – ExtraSun Jul 06 '21 at 07:22
  • it means setStage will be called with value 2 whenever that function is fired. As we are passing it to MobileNum component whenever afterSubmit gets fired it will set value of stage to 2, similarly 3 for IdNumber and 4 for CreatePassword. If you don't want to use bind ,you can pass setStage directly as prop to components and set the value inside the components. – Anish Antony Jul 06 '21 at 07:30
  • https://stackoverflow.com/questions/2236747/what-is-the-use-of-the-javascript-bind-method – Anish Antony Jul 06 '21 at 07:38
  • 1
    Ok I know but why it came with a `null` inside the brackets ? And my App.js is a class component, so it must work differently. – ExtraSun Jul 06 '21 at 07:46
  • 1
    First parameter of bind is an object which will act as this in the new function, since it was a functional component ,it wasn't required . For class based component we have to pass this in place of null.I have added class based component in example – Anish Antony Jul 06 '21 at 08:06
  • 1
    The explanation of `null` leads to `this`. So now I need to know whats the `this` keyboard is for. Please ,thanks – ExtraSun Jul 06 '21 at 08:37
  • That's quite a big concept to be explained in a comment. Refer this question https://stackoverflow.com/questions/3127429/how-does-the-this-keyword-work – Anish Antony Jul 06 '21 at 08:41
  • Wow there is so much to read about the `this` keyboard. I leared about it in a JavaScript course, I only need to know about `this` and `null` inside `setState` – ExtraSun Jul 06 '21 at 10:03
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/234554/discussion-between-anish-antony-and-sunny). – Anish Antony Jul 06 '21 at 10:04
3

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.

Linda Paiste
  • 38,446
  • 6
  • 64
  • 102
  • 1
    Thank you so much Linda for your answer , It's very long I appreciate your effort. I figured it out through your explanation that I will better use a multi-step wizard on one URL as you claimed. :) – ExtraSun Jul 06 '21 at 10:50
  • I honestly didn’t think it would be so long until I started writing it / working on it. And the more complexities I came across the more I’m like “yeah, don’t do this” – Linda Paiste Jul 06 '21 at 15:22
0

I don't think adding React Router or any library changes the way how we solve a problem in React.

Your earlier approach was fine. You could wrap all it in a new component, like,

class MultiStepForm extends React.Component {
  constructor(props) {
    super(props);

    this.state = {
       askMobile: true,
     }; 
  };

  askIdentification = (passed) => {
    if (passed) {
      this.setState({ askMobile: false });
    }
  };

  render() {
    return (
      <div className='App-div'>
        <Header />
        {this.state.askMobile ? (
          <MobileNum legit={this.askIdentification} />
        ) : (
          <IdNumber />
        )}
      </div>
    );
  }
}

Then use this component on your Route.

...
<Switch>
  <Route path='/' component={MultiStepForm} />
  // <Route path='/'  component={MobileNum} />                                                    
  // <Route path='/'  component={IdNumber} />
  // <Route path='/'  component={CreatePassword } />
</Switch>
...

Now how you'd like to move on with this is a completely new question. Also, I have corrected the spelling of askIdentification.

Badal Saibo
  • 2,499
  • 11
  • 23