RogerBanks
RogerBanks

Reputation: 139

Scroll to first invalid field with formik and useRefs react

I'm trying to adapt the code below to use react useRef as opposed to using document.querySelector(selector) as HTMLElement; as it's not the best practice in react. I'm trying to achieve functionality that scrolls to the first error on a Formik form, this code does work but how I can do this with React useRef instead?

here is the code:

import React, { useEffect } from 'react';
import { useFormikContext } from 'formik';

const FocusError = () => {
  const { errors, isSubmitting, isValidating } = useFormikContext();

  useEffect(() => {
    if (isSubmitting && !isValidating) {
      let keys = Object.keys(errors);
      if (keys.length > 0) {
        const selector = `[name=${keys[0]}]`;
        const errorElement = document.querySelector(selector) as HTMLElement;
        if (errorElement) {
          errorElement.focus();
        }
      }
    }
  }, [errors, isSubmitting, isValidating]);

  return null;
};

export default FocusError;

//Put it within formiks Form.

<Formik ...>
  <Form>
     ...
    <FocusError />
  </Form>
</Formik>

Upvotes: 6

Views: 16266

Answers (6)

Kirsten
Kirsten

Reputation: 1


type ScrollToErrorsProps = {
  name?:                string;
  id?:                  string | any;
  formik?:              any;
};

export default class FocusError extends React.Component<ScrollToErrorsProps>{
    componentDidUpdate(prevProps) {
    const { name, id, formik } = this.props;
        if (this.props.formik.submitCount > 0 && this.props.formik.isValid === false) {
            if (Object.keys(this.props.formik.errors)[0] === this.props.name) {
                const el = document.getElementById(this.props.id);
        if (el) {
                  el.scrollIntoView({behavior: "smooth", block: "center", inline: "nearest"});
        }
            }
        }
    }

    render() {
        return null;
    }
}

Upvotes: 0

danielnixon
danielnixon

Reputation: 4268

Here is a slight update to 4nduril's answer.

This variation:

  1. uses focus instead of only using scrollIntoView (better accessibility - see https://webaim.org/techniques/formvalidation/). Note that scrolling (including smooth scrolling) is still available in addition to setting focus.
  2. uses usePrevious (https://ahooks.js.org/hooks/use-previous/) to only set focus to the invalid field after submitting has transitioned from true to false. This avoids the limitation where Formik's validateOnBlur has to be set to true in 4nduril's answer.
  3. ensure the input label is always scrolled into view while the input itself is focused.
export const useFocusInvalidField = <
  T extends HTMLElement,
  U extends HTMLElement,
>(
  name: string,
  smoothScroll?: boolean,
) => {
  const formikContext = useFormikContext();
  // The input/select itself, this is what we will be focusing.
  const fieldRef = useRef<T>(null);
  // The container element that contains both the input/select and its label.
  // This is what we will be scrolling into view. Doing this helps guarantee that
  // the label will be visible. If the submit button is at the bottom of the form and the
  // label is above the invalid input, _just_ focusing the input could leave the
  // label offscreen (above the viewport) which can make it difficult for the user to
  // identify the invalid field.
  const containerElementRef = useRef<U>(null);

  const wasSubmitting = usePrevious(formikContext.isSubmitting);

  useEffect(() => {
    const firstError = Object.keys(formikContext.errors)[0];
    if (
      wasSubmitting === true &&
      !formikContext.isSubmitting &&
      firstError === name &&
      fieldRef.current !== null
    ) {
      // Use a helper function from https://github.com/oaf-project/oaf-side-effects.
      // This does some browser compatibility and accessibility work for us.
      // eslint-disable-next-line @typescript-eslint/no-floating-promises
      focusAndScrollIntoViewIfRequired(
        fieldRef.current, // focus the input/select
        containerElementRef.current ?? fieldRef.current, // scroll the container (and label) into view if set, otherwise scroll to the input
        smoothScroll, // optionally use smooth scroll. Accessibility note: this will ignore smooth scroll if the user has indicated a preference for reduced motion.
      );
    }
  }, [
    wasSubmitting,
    formikContext.isSubmitting,
    name,
    formikContext.errors,
    smoothScroll,
  ]);
  return { fieldRef, containerElementRef };
};

Upvotes: 2

Chris
Chris

Reputation: 28064

There are already some good answers here, but I ended up taking a slightly different route that some might find appealing.

// in my ScrollToError.tsx file...
import { useFormikContext } from 'formik';
import React, { useEffect } from 'react';

export function ScrollToError() {
    const formik = useFormikContext();
    const submitting = formik?.isSubmitting;

    useEffect(() => {
        const el = document.querySelector('.Mui-error, [data-error]');
        (el?.parentElement ?? el)?.scrollIntoView();
    }, [submitting]);
    return null;
}

// later inside my <Formik/> component...
<ScrollToError/>

This finds the first .Mui-error element (typically the label for the field in question) and focuses its parent, or itself if no parent. In some cases, I add an <Alert severity="error"/> component to display a general error message, which this also finds if I tag it with the data-error attribute. The scroll only happens when isSubmitting changes.

Upvotes: 19

Fullstack developer
Fullstack developer

Reputation: 21

i am using useFormik and made useEffect hook like this.


  const formik = useFormik({
    initialValues: initialValues,
    validationSchema: validators,
    onSubmit: (values, {setErrors}) => {
      ....
    }
  });
  useEffect(() => {
    if (!formik.isSubmitting) return;
    if (Object.keys(formik.errors).length > 0) {
      document.getElementsByName(Object.keys(formik.errors)[0])[0].focus();
    }
  }, [formik]);

and it set focus to first invalid input field. i hope this helps others.

Upvotes: 1

4nduril
4nduril

Reputation: 379

My solution was to build it into my custom form fields. I use MaterialUI TextFields but you can do it of course without. The relevant Code:

import { TextField as MuiTextField } from '@material-ui/core'

export const TextField = ({ name, ...rest }) => {
  const [field, meta] = useField({ name, type: 'text' })
  const formikBag = useFormikContext()
  const fieldRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    const firstError = Object.keys(formikBag.errors)[0]
    if (formikBag.isSubmitting && firstError === name) {
      fieldRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
    }
  }, [meta.error, formikBag.isSubmitting, name, formikBag.errors])

  return (
    <MuiTextField
      id={field.name}
      ref={fieldRef}
      error={meta.touched && Boolean(meta.error)}
      helperText={meta.touched && meta.error}
      {...rest}
      {...field}
    />
  )
}

You can even extract it to a custom hook like this:

import { useFormikContext } from 'formik'
import { useRef, useEffect, MutableRefObject } from 'react'

export const useScrollToInvalidField = <T extends HTMLElement>(name: string): MutableRefObject<T | null> => {
  const formikBag = useFormikContext()
  const fieldRef = useRef<T>(null)

  useEffect(() => {
    const firstError = Object.keys(formikBag.errors)[0]
    if (formikBag.isSubmitting && firstError === name) {
      fieldRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
    }
  }, [formikBag.isSubmitting, name, formikBag.errors])
  return fieldRef
}

Then you just give it the correct Element type of your field (e.g. HTMLDivElement) and attach the ref to the form field component.

Upvotes: 1

Bikas
Bikas

Reputation: 2759

Instead of doing -

errorElement.focus();

Do this -

errorElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
errorElement.focus({ preventScroll: true });

Upvotes: 6

Related Questions