Sign in
Log inSign up
Dynamic Form Object Rendering with Formik + Material UI

Dynamic Form Object Rendering with Formik + Material UI

Yomi Onisade's photo
Yomi Onisade
·Apr 16, 2021·

6 min read

Recently, I was tasked with rendering a form dynamically based on input fields gotten from backend. Rendering the form was no problem but integrating the dynamic form with material ui, validation with formik, making it reusable in other components and also formatting the input field which is an amount field was a bit of a conundrum for me. 3 stack overflow searches and a Ben award's video later, I was able to render the form dynamically and add validation to it. I will be outlining the process in this article and hope you enjoy it.

Not to bore you with the superficial details, let's assume i have already installed @material-ui/core, yup, formik to my react app

npm i @material-ui/core yup formik @material-ui/lab

Setting up App.js

I will create a new component named AddFeeModal to encapsulate my form

import AddFeeModal from './AddFeeModal'
export default function App() {
  return (
    <div className="App">
     <AddFeeModal />
    </div>
  );
}

Making it Reusable

In order to make it reusable, I decided to set up a separate component for it and pass form down to it as props to another component called SetFeeModal . Take note that we can add other form values at the top level here, but I want to keep the logic for the dynamic form separate.

import { useState} from 'react'
import { Button, Modal } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles';

import { useFormik } from 'formik'
import * as yup from 'yup'

import NewSetFeeModal from './NewSetFeeModal'

const useStyles = makeStyles((theme) => ({
  modal: {
    display: 'flex',
    alignItems: 'center',
    justifyContent: 'center',
  }
}));

const AddFeeModal = () => {
  const classes = useStyles();
  const [feeModal, setFeeModal] = useState(false);

  const openFeeModal = () => setFeeModal(true);
  const closeFeeModal = () => setFeeModal(false);

const submitHandler = async val => {
    console.log(val);

  };

  const validationSchema = yup.object({
    feeFields: yup.array().of(
      yup.object().shape({
        value: yup
          .number()
          .required("The amount is required")
          .min(1, "Please enter an amount"),
      })
    ),
  });

  const formik = useFormik({
    initialValues: {
      feeFields: [],
    },
    validationSchema,
    onSubmit: submitHandler,
  });

return (
  <>
<form onSubmit={formik.handleSubmit}>
 <Button 
 color="secondary"
 variant="contained"
onClick={openFeeModal} >
           Set Fee
  </Button>
  <pre>
    {JSON.stringify(formik.values, null, 2)}
  </pre>
</form>

   <Modal
        open={feeModal}
        className={classes.modal}
        onClose={closeFeeModal}
        width={700}
      >
        <NewSetFeeModal formik={formik} />
    </Modal>
</>
)
}

export default AddFeeModal

Breaking down the code

I imported the need component at the top

  1. makeStyles to style my material ui modal
  2. useFormik to instantiate formik
  3. yup to validate the formik
  4. NewSetFeeModal to encapsulate the dynamic form
  5. useStyles to create the styles i need for the modal
  6. feeModal and setFeeModal to open the modal
  7. submitHandler to submit our formik form if it is valid
  8. valdiationSchema: this is where i validate each field of the dynamic array to be rendered
  9. formik: this is where i instantiate my useFormik with initial values, validationSchema(yup) and submit handler
  10. Notice the
     tag after the button: This is to show the formik values onChange

Setting up the NewSetFeeModal

The next step is to set up the component itself. This is where i encountered another problem. I could have used the push props provided by formik FieldArray, but since the field is dynamic, the title/label will always be different and the I want the user to to be able to search, select and deselect as many field as they want. I decided to use material-ui autocomplete from @material-ui/lab.

Also to mimic the type of data that will be sent from the backend I will create a file for dynamic data called dynamicData.js

const dynamicData = [{id: 1, name: "Legal Fee"}, {id: 2, name: "Agent Commission"}, {id: 3, name: "Caution Deposit"}]

export default dynamicData

Now for setFeeModal Component

import { useEffect, useState } from "react";
import {
  FieldArray,
  getIn,
  FormikProvider,
} from "formik";
import { Autocomplete } from "@material-ui/lab";
import {
  Grid,
  TextField,
  Checkbox,
  MenuItem
} from "@material-ui/core";
import { makeStyles } from '@material-ui/core/styles';
import { MdCheckBoxOutlineBlank, MdCheckBox } from "react-icons/md";

import dynamicData from './dynamicData'

const icon = <MdCheckBoxOutlineBlank fontSize="small" />;
const checkedIcon = <MdCheckBox fontSize="small" />;

const useStyles = makeStyles((theme) => ({
  paper: {
    position: 'absolute',
    width: 600,
    backgroundColor: theme.palette.background.paper,
    border: '2px solid #000',
    boxShadow: theme.shadows[5],
    padding: theme.spacing(2, 4, 3),
  },
}));


const NewSetFeeModal = ({formik }) => {
  const classes = useStyles();
  let currency = [{code: "1", id: 1, major_symbol: "₦", name: "Naira"}]
  const [fields, setFields] = useState([]);


  const fetchServiceFees = () => {
          let newFields = [...dynamicData].map(item => {
            item.value = "";
            item.formattedValue = "";
            item.currencyField = 1;
            return item;
          });
          setFields(newFields);
  };

  useEffect(() => {
    fetchServiceFees();
  }, []);


  return (
    <>
     <div  className={classes.paper}>
     <h2>Set Fee</h2>
      <FormikProvider 

      value={formik}>
        <Grid container spacing={2}>
          <FieldArray name="feeFields">
            {() => {
              return (
                <>
                  <Grid item xs={12}>
                    <Autocomplete
                      id="feeFields"
                      name="feeFields"
                      multiple
                      options={fields}
                      disableCloseOnSelect
                      size="small"
                      getOptionLabel={option => option.name}
                      onChange={(e, value) => {
                        let oldFields = {};
                        let newFields = {};
                        let feeFields = formik.values.feeFields;
                        feeFields.forEach(item => {
                          oldFields[item.id] = item;
                        });
                        value.forEach(item => {
                          if (oldFields[item.id]) {
                            newFields[item.id] = oldFields[item.id];
                          } else {
                            newFields[item.id] = item;
                          }
                        });
                        let newArray = Object.values(newFields);
                        formik.setFieldValue("feeFields", newArray);
                      }}
                      renderOption={(option, { selected }) => (
                        <>
                          <Checkbox
                            icon={icon}
                            checkedIcon={checkedIcon}
                            style={{ marginRight: 8 }}
                            checked={selected}
                          />
                          {option.name}
                        </>
                      )}
                      renderInput={params => (
                        <TextField
                          {...params}
                          variant="outlined"
                          label="Pick Fee"
                          onChange={formik.handleChange}
                          onBlur={formik.handleBlur}
                          value={formik.values.feeFields}
                        />
                      )}
                    />
                  </Grid>

                  {Array.isArray(formik.values.feeFields) && 
                    formik.values.feeFields.map((item, id) => {
                    let name = `feeFields[${id}].name`;
                    let value = `feeFields[${id}].value`;
                    let errorMessage = getIn(formik.errors, value);

                    return (
                      <>
                        <Grid key={id} item container spacing={2}>
                          <Grid item xs={3} sm={3}>
                            <TextField
                              name={`feeFields[${id}].currencyField`}
                              label="Currency"
                              select
                              variant="outlined"
                              size="small"
                              fullWidth
                              value={item.currencyField}
                            >
                              {currency.map(({ code, major_symbol }) => (
                                <MenuItem value={code}>{major_symbol}</MenuItem>
                              ))}
                            </TextField>
                          </Grid>
                          <Grid item xs={9}>
                            <TextField
                              name={name}
                              label={item.name}
                              variant="outlined"
                              size="small"
                              fullWidth
                              value={
                                item.formattedValue.length > 1
                                  ? item.formattedValue
                                  : item.value
                              }
                              error={errorMessage}
                              helperText={errorMessage}
                              onChange={(e , value)=> {
                                if (
                                  isNaN(parseInt(e.nativeEvent.data)) &&
                                  e.nativeEvent.inputType !==
                                    "deleteContentBackward"
                                )
                                  return;

                                let format = formatNumbers(
                                  parseInt(
                                    Number(
                                      e.target.value.replace(/[^0-9.-]+/g, "")
                                    )
                                  )
                                )
                                formik.setFieldValue(
                                  `feeFields[${id}].formattedValue`,
                                  format
                                );
                                formik.setFieldValue(
                                  `feeFields[${id}].value`,
                                  Number(
                                    e.target.value.replace(/[^0-9.-]+/g, "")
                                  )
                                );
                              }}
                              onBlur={formik.handleBlur}
                            />
                          </Grid>
                        </Grid>
                      </>
                    );
                  })}
                </>
              );
            }}
          </FieldArray>
        </Grid>
      </FormikProvider>
      </div>
    </>
  );
};

export default NewSetFeeModal;

export const formatNumbers = num => {
  return num.toFixed(0).replace(/(\d)(?=(\d{3})+(?!\d))/g, "$1,");
};

Code Breakdown

  1. I imported the needed component
  2. useStyles to style the body of the modal
  3. currency to set the currency field
  4. setting fields and setFields as an empty array to store the data fetched from the API
  5. fetchServiceFees as the fake API call function to fetch our data and set it in the fields array
  6. useEffect with empty array dependency to make the code run once when the component loads
  7. The Autocomplete to search for fields and select multiple tags
  8. onChange: Since the value of the autocomplete multiple is an array, it was difficult for me to get the last value added or removed, therefore, I decided to memoize the old value and return it. You may be surprised about the reason for this long function. Because I am not using FieldArray push and remove, using the autocomplete mode will cause a new array of objects to be created each time the user decides to add or remove a field. Consequently, this will clear the field/fields data the user has already entered. To prevent this, I set up that process to store former data in the old fields and return it when adding or removing field(s).
  9. Name, value, errorMessage: this is to get the value, name, and errors from formik since it is nested
  10. onChange for value: this is where I format the input field to currency

Our component is ready to be tested.

You can check the working example here

https://codesandbox.io/s/formik-dynamic-form-rendering-forked-tbs0j?file=/src/NewSetFeeModal.js

Thank you for the read.