DEV Community

Cover image for The Save Button That Did Nothing
Violetta Solovyova
Violetta Solovyova

Posted on Originally published at violetsoloveva.substack.com

The Save Button That Did Nothing

Have you ever clicked Save on a long form and watched absolutely nothing happen?

No toast. No red text. No movement. Just a button that seems to be ignoring you.

That was our app. We build long forms: profile pages, multi-section settings, repeatable blocks like "contacts" or "addresses". Here's what kept happening:

  1. The user fills in the visible part of the form and clicks Save, which sits in a sticky header.
  2. Validation fails on a field far below the fold, or inside the third item of a repeatable list.
  3. Nothing visible happens. The user clicks Save again, then gives up or reports a bug.

The error was there all along, sitting politely at the bottom of the page where nobody could see it.

Server-side validation had the same problem. The form submits, the backend answers 400 with field errors, we map them into the form, and the screen still doesn't change.

We needed one consistent rule for every form: when validation fails, bring the first error into view.

What we were working with

  • React Hook Form (RHF) for form state, with Zod schemas through zodResolver.
  • MUI inputs, plus many composite controls: autocompletes, date pickers, chip selectors, custom list widgets.
  • React Router for navigation. Some forms are reused across tabs and routes.
  • A shared FormItem wrapper that renders the label, the control and the error message for every field.

"Doesn't RHF already do this?"

Sort of. RHF has shouldFocusError, and it's on by default. It focuses the first invalid field registered with a focusable ref.

It works well for plain inputs. But in our case:

  • Composite controls often don't expose a focusable ref, or they expose the wrong element.
  • Array-level errors (for example "add at least one contact") belong to no input, so there's nothing to focus.
  • Some fields are read-only displays of a value picked in a dialog. Focusing them is pointless.
  • Focus doesn't always scroll to a comfortable position. The field can land right under the sticky header.

So instead of chasing inputs, we decided to target the error message element.

The approach

Four small steps: mark, search, scroll, wait.

1. Mark: tag every error message with its field path

The shared FormItem already renders the error text. We add one attribute to it: the RHF field path.

{errorMessage && (
  <FormHelperText data-name={name}>
    {errorMessage}
  </FormHelperText>
)}
Enter fullscreen mode Exit fullscreen mode

Why the error message and not the input?

  • It only exists when there's an error, so the selector never matches a valid field.
  • It works for any control type, including composites and custom widgets.
  • It works for errors with no input at all. A list-level error can render its own message with data-name="contacts".

Now every visible error has a predictable, framework-agnostic address in the DOM, like [data-name="contacts.2.email"].

2. Search: find the path of the first error

RHF errors mirror the shape of your form values:

// Plain field
{ title: { type: 'too_small', message: 'Required' } }

// Field array: sparse, only invalid items are present
{ contacts: [ , , { email: { message: 'Invalid email' } } ] }

// Array-level error
{ contacts: { type: 'too_small', message: 'Add a contact' } }
Enter fullscreen mode Exit fullscreen mode

We need to turn each shape into a path that matches our data-name:

  • { title: {...} } → title
  • { contacts: [ , , { email: {...} } ] } → contacts.2.email
  • { contacts: { message: ... } } → contacts

The algorithm:

  1. Take the first key of the errors object. That's the first failing top-level field.
  2. Check whether its value has index keys ("0", "2", ...). If it does, it's a field array error.
  3. For a field array, take the first index present, then the first sub-field inside that item.
  4. Otherwise, use the top-level name as is.

How do you tell the two shapes apart? A plain FieldError has keys like type, message and ref. A field array error has "0", "2" and so on. One regex is enough:

const isIndex = (key: string) => /^\d+$/.test(key);
Enter fullscreen mode Exit fullscreen mode

And "first index" really means the lowest one. JavaScript returns integer-like keys first, in ascending order, so Object.keys on a sparse error array always starts with the earliest invalid item.

3. Scroll: let the browser do it

Once we have a path, scrolling is a single DOM call:

document
  .querySelector(`[data-name="${path}"]`)
  ?.scrollIntoView({ behavior: 'smooth', block: 'center' });
Enter fullscreen mode Exit fullscreen mode

block: 'center' matters. With a sticky header, start often hides the field right under it. Centering keeps the label, the control and the message all visible. If you'd rather use start, set scroll-margin-top in CSS to the header's height.

Two extra details in the final version:

  • Focus, not just scroll. Seeing the field isn't enough for keyboard and screen-reader users. After scrolling, we focus the first focusable element inside the same FormItem.
  • Reduced motion. If the user has prefers-reduced-motion enabled, we jump instead of animating.

4. Wait: let the page settle first

Our layout scrolls the page container to the top on every route change. When a form is submitted and the app navigates or re-renders a route, both scrolls race and neither works.

The quick fix is a setTimeout with a guess, say one second. But is one second enough? On a fast laptop it's too long. On a slow phone it might be too short.

Instead of guessing, we wait for the error message to settle. On every animation frame, we check two things:

  1. Does the element exist yet?
  2. Is it in the same place as in the previous frame?

When both are true, nothing is moving anymore, and it's safe to scroll. Here's the core of it, simplified:

const check = () => {
  const element = document.querySelector<HTMLElement>(selector);
  const top = element?.getBoundingClientRect().top ?? null;

  if (element && top === lastTop) {
    callback(element);
    return;
  }

  lastTop = top;
  frameId = requestAnimationFrame(check);
};
Enter fullscreen mode Exit fullscreen mode

This covers more than the route scroll. It also waits for content that renders late, like a list that loads its items or a section that expands with an animation. As a safety net, the loop stops after about a second (MAX_FRAMES) and scrolls to wherever the element is.

The helper returns a cancel function, and the hook returns it from the effect. A fast re-validation or an unmount never triggers a stale scroll. The effect also depends on location.pathname, so it runs again when the same form instance is shown on another route or tab.

A catch: isValid and server errors

A natural guard is to run the hook only when isValid is false. No errors, nothing to scroll to, right?

Not for server errors. setError doesn't touch isValid, because RHF derives it from schema validation only. When the client-side checks pass and the server returns a 400, isValid stays true, and a hook guarded by it would never scroll.

So the hook ignores isValid and reads the errors directly. If there's a path, it scrolls. submitCount is also in the dependencies, so submitting the same invalid form again scrolls again, even when the errors haven't changed.

Usage

Call the hook once per form:

const ProfileForm = () => {
  const form = useForm<ProfileFormValues>({
    resolver: zodResolver(profileSchema),
  });
  const { errors, submitCount } = form.formState;

  useScrollToFieldError({ errors, submitCount });

  return (
    <FormProvider {...form}>
      <FormItem name="title" label="Title">...</FormItem>
      <ContactsList name="contacts" />
    </FormProvider>
  );
};
Enter fullscreen mode Exit fullscreen mode

Server errors work the same way. Map them into the form with setError, and the hook scrolls to the first one.

Trade-offs and limitations

  • "First" means first key, not first on screen. Top-level key order follows the order errors were added. That usually matches field order, but it isn't guaranteed. For a stricter version, collect all matching elements and pick the one with the smallest getBoundingClientRect().top.
  • One level of nesting. contacts.2.email works. sections.1.items.3.name doesn't. A recursive walk fixes that if you need it.
  • "Settled" is a heuristic. "Didn't move since the last frame" is a good signal, but it isn't an explicit "layout is ready" event. If your app has one, use it. MAX_FRAMES caps the wait either way.
  • Convention-based. It only works if every error message renders data-name. Putting that in one shared FormItem made it easy to enforce.

Conclusion

A small amount of code removed a whole class of "the Save button does nothing" reports. The ideas carry over to any form library:

  • Address errors, not inputs. Tag the error message with the field path. It exists only when needed and works for every kind of control.
  • Derive the path from the error tree. An "index keys" check tells plain fields from field arrays.
  • Let the browser scroll. scrollIntoView({ block: 'center' }) handles sticky headers well. Then move focus for accessibility.
  • Don't guess delays. Wait until the element stops moving, cancel on cleanup, and re-run on route change and re-submit.

Full code

waitForSettledElement.ts

// Safety net if the layout never settles: ~1s at 60fps.
const MAX_FRAMES = 60;

// Calls `callback` once the element exists and hasn't moved
// since the previous frame. Returns a cancel function.
export function waitForSettledElement(
  selector: string,
  callback: (element: HTMLElement) => void,
) {
  let frameId = 0;
  let frames = 0;
  let lastTop: number | null = null;

  const check = () => {
    const element = document.querySelector<HTMLElement>(selector);
    const top = element?.getBoundingClientRect().top ?? null;
    frames += 1;

    if (element && (top === lastTop || frames >= MAX_FRAMES)) {
      callback(element);
      return;
    }

    if (frames < MAX_FRAMES) {
      lastTop = top;
      frameId = requestAnimationFrame(check);
    }
  };

  frameId = requestAnimationFrame(check);

  return () => cancelAnimationFrame(frameId);
}
Enter fullscreen mode Exit fullscreen mode

scrollToFieldError.ts

import { waitForSettledElement } from './waitForSettledElement';

const FOCUSABLE = [
  'input:not([disabled])',
  'textarea:not([disabled])',
  'select:not([disabled])',
  'button:not([disabled])',
  '[tabindex]:not([tabindex="-1"])',
].join(', ');

function prefersReducedMotion() {
  return window.matchMedia('(prefers-reduced-motion: reduce)')
    .matches;
}

function reveal(message: HTMLElement) {
  message.scrollIntoView({
    behavior: prefersReducedMotion() ? 'auto' : 'smooth',
    block: 'center',
  });

  // Keyboard and screen-reader users need focus, not just scroll.
  message
    .closest('[data-form-item]')
    ?.querySelector<HTMLElement>(FOCUSABLE)
    ?.focus({ preventScroll: true });
}

// Returns a cancel function.
export function scrollToFieldError(path: string) {
  const selector = `[data-name="${CSS.escape(path)}"]`;

  return waitForSettledElement(selector, reveal);
}
Enter fullscreen mode Exit fullscreen mode

getFirstErrorPath.ts

import { type FieldErrors } from 'react-hook-form';

type ErrorNode = Record<string, unknown> | undefined;

const isIndex = (key: string) => /^\d+$/.test(key);

function hasIndexKeys(node: ErrorNode) {
  return node !== undefined && Object.keys(node).some(isIndex);
}

function getArrayFieldPath(name: string, node: ErrorNode) {
  // Integer-like keys come first, in ascending order.
  const index = Object.keys(node ?? {}).find(isIndex);
  if (index === undefined) {
    return '';
  }

  const item = node?.[index] as ErrorNode;
  const [subField] = Object.keys(item ?? {});

  return subField ? `${name}.${index}.${subField}` : '';
}

export function getFirstErrorPath(errors: FieldErrors) {
  const [name] = Object.keys(errors);
  if (!name) {
    return '';
  }

  const node = errors[name] as ErrorNode;

  return hasIndexKeys(node)
    ? getArrayFieldPath(name, node)
    : name;
}
Enter fullscreen mode Exit fullscreen mode

useScrollToFieldError.ts

import { useEffect } from 'react';
import { type FieldErrors, type FieldValues } from 'react-hook-form';
import { useLocation } from 'react-router';

import { getFirstErrorPath } from './getFirstErrorPath';
import { scrollToFieldError } from './scrollToFieldError';

type Config<T extends FieldValues> = Readonly<{
  errors: FieldErrors<T>;
  submitCount: number;
}>;

export const useScrollToFieldError = <T extends FieldValues>({
  errors,
  submitCount,
}: Config<T>) => {
  const { pathname } = useLocation();

  useEffect(() => {
    // Don't check isValid: setError() doesn't change it,
    // so server errors would be skipped.
    const path = getFirstErrorPath(errors);
    if (!path) {
      return;
    }

    return scrollToFieldError(path);
  }, [errors, submitCount, pathname]);
};
Enter fullscreen mode Exit fullscreen mode

FormItem.tsx (simplified)

import { type FC, type PropsWithChildren } from 'react';
import {
  FormControl,
  FormHelperText,
  FormLabel,
} from '@mui/material';
import { useFormState } from 'react-hook-form';
import { get } from 'lodash';

type Props = PropsWithChildren<Readonly<{
  name: string;
  label: string;
}>>;

export const FormItem: FC<Props> = ({ name, label, children }) => {
  const { errors } = useFormState({ name });
  const errorMessage = get(errors, name)?.message as
    | string
    | undefined;

  return (
    <FormControl
      data-form-item
      error={Boolean(errorMessage)}
      fullWidth
    >
      <FormLabel>{label}</FormLabel>
      {children}
      {errorMessage && (
        <FormHelperText data-name={name}>
          {errorMessage}
        </FormHelperText>
      )}
    </FormControl>
  );
};
Enter fullscreen mode Exit fullscreen mode

Field array example

import { useFieldArray, useFormContext } from 'react-hook-form';
import { FormHelperText, TextField } from '@mui/material';

type Props = Readonly<{ name: 'contacts' }>;

export const ContactsList = ({ name }: Props) => {
  const { control, register, formState } =
    useFormContext<ProfileFormValues>();
  const { fields } = useFieldArray({ control, name });

  const listError =
    formState.errors.contacts?.message ??
    formState.errors.contacts?.root?.message;

  return (
    <>
      {listError && (
        <FormHelperText error data-name={name}>
          {listError}
        </FormHelperText>
      )}

      {fields.map((field, index) => (
        <FormItem
          key={field.id}
          name={`${name}.${index}.email`}
          label="Email"
        >
          <TextField {...register(`${name}.${index}.email`)} />
        </FormItem>
      ))}
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

Schema used in the examples

import { z } from 'zod';

const contactSchema = z.object({
  email: z.email('Invalid email'),
});

export const profileSchema = z.object({
  title: z.string().min(1, 'Required'),
  contacts: z.array(contactSchema).min(1, 'Add a contact'),
});

export type ProfileFormValues = z.infer<typeof profileSchema>;
Enter fullscreen mode Exit fullscreen mode

Top comments (0)