Skip to content

Form handling

Formik logo

Building forms with a good user experience takes a lot of effort, for example the touched, error, and focused states must be managed for each field. This is made much easier by using Formik to handle the states and yup to validate the data.

Formik and yup are installed via npm,

npm install --save formik yup

Integrating Formik with Material-UI

Formik integrates nicely with Material-UI components via Formik's useField hook. This hook takes care of the form state aspects, but doesn't account for any label, helper text or required marker which will need to be specified by props.

I like the styling of outlined inputs with normal margins, which is what I'll use in the components below.

Checkbox Field

We'll need a checkbox field to indicate if a Todo is complete or to indicate that the user should be remembered on login. The following should be added to frontend/src/components/CheckboxField.tsx,

import Checkbox from "@material-ui/core/Checkbox";
import FormControl from "@material-ui/core/FormControl";
import FormControlLabel from "@material-ui/core/FormControlLabel";
import FormHelperText from "@material-ui/core/FormHelperText";
import { FieldHookConfig, useField } from "formik";
import React from "react";

type IProps = FieldHookConfig<boolean> & {
  fullWidth?: boolean;
  helperText?: string;
  label: string;
  required?: boolean;
};

const CheckboxField = (props: IProps) => {
  const [field, meta] = useField<boolean>(props);

  let helperText: any;
  if (Boolean(meta.error) && meta.touched) {
    helperText = `${meta.error}. ${props.helperText ?? ""}`;
  } else {
    helperText = props.helperText;
  }

  return (
    <FormControl
      component="fieldset"
      error={Boolean(meta.error) && meta.touched}
      fullWidth={props.fullWidth}
      margin="normal"
      required={props.required}
    >
      <FormControlLabel
        control={<Checkbox {...field} checked={field.value} />}
        label={props.label}
      />
      <FormHelperText>{helperText}</FormHelperText>
    </FormControl>
  );
};

export default CheckboxField;

Date Field

We'll need a date field for the user to specify a due date for a Todo. The following should be added to frontend/src/components/DateField.tsx,

import MUITextField, { TextFieldProps } from "@material-ui/core/TextField";
import { format, parseISO } from "date-fns";
import { FieldHookConfig, useField } from "formik";
import React from "react";
import { useUID } from "react-uid";

const DateField = (props: FieldHookConfig<Date> & TextFieldProps) => {
  const id = useUID();
  const [field, meta, helpers] = useField<Date>(props);

  let helperText: any;
  if (Boolean(meta.error) && meta.touched) {
    helperText = `${meta.error}. ${props.helperText ?? ""}`;
  } else {
    helperText = props.helperText;
  }

  const value = field.value ? format(field.value, "yyyy-MM-dd") : "";

  return (
    <MUITextField
      {...props}
      error={Boolean(meta.error) && meta.touched}
      helperText={helperText}
      id={id}
      InputLabelProps={{
        shrink: true,
      }}
      margin="normal"
      type="date"
      variant="outlined"
      {...field}
      onChange={(event) => {
        if (event.target.value) {
          helpers.setValue(parseISO(event.target.value));
        } else {
          helpers.setValue(null);
        }
      }}
      value={value}
    />
  );
};

export default DateField;

Note that the label must always be in the shrunk state to stop it overlapping with any input mask added by the browser.

Email Field

We'll need an email field for the user to login. The following should be added to frontend/src/components/EmailField.tsx,

import MUITextField, { TextFieldProps } from "@material-ui/core/TextField";
import { FieldHookConfig, useField } from "formik";
import React from "react";
import { useUID } from "react-uid";

const EmailField = (props: FieldHookConfig<string> & TextFieldProps) => {
  const id = useUID();
  const [field, meta] = useField<string>(props);

  let helperText: any;
  if (Boolean(meta.error) && meta.touched) {
    helperText = `${meta.error}. ${props.helperText ?? ""}`;
  } else {
    helperText = props.helperText;
  }

  return (
    <MUITextField
      {...props}
      autoComplete="email"
      error={Boolean(meta.error) && meta.touched}
      helperText={helperText}
      id={id}
      margin="normal"
      type="email"
      variant="outlined"
      {...field}
    />
  );
};

export default EmailField;

Password Field

We'll need the password field for the user to login and change their password. Unlike the other fields the password field has a button to toggle visibility of the password, which helps the user get their password correct. Note though this button is taken out of the tab flow, as users don't expect to tab onto this button. The following should be added to frontend/src/components/PasswordField.tsx,

import IconButton from "@material-ui/core/IconButton";
import InputAdornment from "@material-ui/core/InputAdornment";
import MUITextField, { TextFieldProps } from "@material-ui/core/TextField";
import Visibility from "@material-ui/icons/Visibility";
import VisibilityOff from "@material-ui/icons/VisibilityOff";
import { FieldHookConfig, useField } from "formik";
import React from "react";
import { useUID } from "react-uid";

const PasswordField = (props: FieldHookConfig<string> & TextFieldProps) => {
  const id = useUID();
  const [field, meta] = useField<string>(props);
  const [showPassword, setShowPassword] = React.useState(false);

  let helperText: any;
  if (Boolean(meta.error) && meta.touched) {
    helperText = `${meta.error}. ${props.helperText ?? ""}`;
  } else {
    helperText = props.helperText;
  }

  return (
    <MUITextField
      {...props}
      InputProps={{
        endAdornment: (
          <InputAdornment position="end">
            <IconButton
              onClick={() => setShowPassword((value) => !value)}
              tabIndex={-1}
            >
              {showPassword ? <Visibility /> : <VisibilityOff />}
            </IconButton>
          </InputAdornment>
        ),
      }}
      error={Boolean(meta.error) && meta.touched}
      helperText={helperText}
      id={id}
      margin="normal"
      type={showPassword ? "text" : "password"}
      variant="outlined"
      {...field}
    />
  );
};

export default PasswordField;

Text Field

We'll also need a text field for content of a todo. The following should be added to frontend/src/components/TextField.tsx,

import MUITextField, { TextFieldProps } from "@material-ui/core/TextField";
import { FieldHookConfig, useField } from "formik";
import React from "react";
import { useUID } from "react-uid";

const TextField = (props: FieldHookConfig<string> & TextFieldProps) => {
  const id = useUID();
  const [field, meta] = useField<string>(props);

  let helperText: any;
  if (Boolean(meta.error) && meta.touched) {
    helperText = `${meta.error}. ${props.helperText ?? ""}`;
  } else {
    helperText = props.helperText;
  }

  return (
    <MUITextField
      {...props}
      error={Boolean(meta.error) && meta.touched}
      helperText={helperText}
      id={id}
      margin="normal"
      type="text"
      variant="outlined"
      {...field}
    />
  );
};

export default TextField;

Submit Button

We should also allow the user to submit the form and show an indication that it is processing i.e. while the request to the backend is taking place. I like to do this by disabling the button and adding a circular spinner over the top whilst the submission is processing. The following should be added to frontend/src/components/SubmitButton.tsx,

import Button from "@material-ui/core/Button";
import CircularProgress from "@material-ui/core/CircularProgress";
import React from "react";
import styled from "styled-components";

interface IDivProps {
  fullwidth?: boolean;
}

const SDiv = styled.div<IDivProps>`
  align-items: center;
  display: inline-flex;
  margin-bottom: 8px;
  margin-right: 8px;
  margin-top: 16px;
  position: relative;
  width: ${(props) => (props.fullwidth ? "100%" : "initial")};

  & svg {
    margin-left: 4px;
  }

  & .MuiCircularProgress-root {
    left: 50%;
    margin-left: -12px;
    margin-top: -12px;
    position: absolute;
    top: 50%;
  }

  & .MuiCircularProgress-root svg {
    margin: 2px;
  }
`;

interface IProps {
  className?: string;
  fullWidth?: boolean;
  label: string;
  submitting?: boolean;
}

const SubmitButton = ({
  className,
  label,
  submitting,
  fullWidth,
}: IProps): JSX.Element => (
  <SDiv className={className} fullwidth={fullWidth}>
    <Button
      color="primary"
      disabled={submitting}
      fullWidth={fullWidth}
      type="submit"
      variant="contained"
    >
      {label}
    </Button>
    {submitting ? <CircularProgress size={24} /> : null}
  </SDiv>
);

export default SubmitButton;

Note the styling is required to position the CircularProgress element in the center of the Button.

Secondary button

Finally we will need a way for the user to change their mind and visit another page, or simply go back i.e. perform a secondary action other than submitting the form. The following should be added to frontend/src/components/SecondaryButton.tsx,

import Button from "@material-ui/core/Button";
import React from "react";
import { Link, LinkProps } from "react-router-dom";
import styled from "styled-components";

const SButton = styled(Button)`
  margin-right: 8px;
` as typeof Button;

interface IProps extends Pick<LinkProps, "to"> {
  label: string;
}

const SecondaryButton = ({ label, to }: IProps) => (
  <SButton component={Link} to={to} variant="outlined">
    {label}
  </SButton>
);

export default SecondaryButton;

Note the styling is required to ensure that the buttons have a space between them.