Skip to main content
ASoc
Tutorial

React Hook Form getValues: The Source Line That Explains Why Your Button Stays Disabled

getValues reads without subscribing — proven from the 7.89.0 source. Why that breaks a disabled button, when to use watch, and the useState gate this repo used instead.

The ASoc Team9 min read

getValues reads React Hook Form's current values without subscribing to them. That single property — no subscription — is why it returns the right value and still cannot update your UI. Reach for it inside event handlers, validators and submit logic; reach for watch when a render has to change. Here is the source that proves the difference, and the gate this codebase built without either.

The short answer

getValueswatch
Returns current valuesYesYes
Re-renders on changeNoYes
Subscribes to the formNoYes
Safe in render to drive UINo — the value is correct but stale on screenYes
Right place to call itEvent handlers, validate functions, onSubmitComponent body, when render output depends on a field
Cost per keystrokeZeroA re-render of the subscribing component

Three signatures, and they are the whole API surface:

const { getValues } = useForm();

getValues();                      // every value, as one object
getValues("email");               // one field
getValues(["email", "password"]);  // an array, in the order you asked

What the source actually does

The package is more convincing than the docs here. This is getValues in react-hook-form 7.89.0, straight out of node_modules/react-hook-form/dist/index.esm.mjs at line 2969:

const getValues = (fieldNames, config) => {
    let values = {
        ...(_state.mount ? _formValues : _defaultValues),
    };
    if (config) {
        values = extractFormValues(config.dirtyFields ? _formState.dirtyFields : _formState.touchedFields, values);
    }
    return isUndefined(fieldNames)
        ? values
        : isString(fieldNames)
            ? get(values, fieldNames)
            : fieldNames.map((name) => get(values, name));
};

Read it for what is absent. There is no setState, no subscribe call, no effect — it spreads a mutable internal object and indexes into it. That is the entire reason it is cheap, and the entire reason it cannot move your UI.

Two details fall out of those six lines that most tutorials skip:

Before mount, you get defaults, not values. _state.mount ? _formValues : _defaultValues is the fallback the docs describe as "prioritizes mounted field values." Call getValues from a module-level closure or a callback that fires before the form mounts and you are reading defaultValues — correctly, but not what you meant.

There is a second parameter. config lets you narrow the result to touched or dirty fields (getValues(undefined, { dirtyFields: true })). Useful for a PATCH-shaped submit that should send only what the user actually edited.

Now compare watch, 66 lines further down at 3035:

const watch = (name, defaultValue) => {
    if (isFunction(name)) {
        _valuesSubscriberCount++;
        const { unsubscribe } = _subjects.state.subscribe({
            next: (payload) => 'values' in payload && ...

_valuesSubscriberCount++ and _subjects.state.subscribe — the subscription machinery getValues has none of. The difference between the two functions is not convenience. It is whether React is told anything happened.

The bug this guarantees

The failure is always the same shape: a value used in render, read through getValues.

// Broken — the button never enables.
export function DeleteForm({ phrase }: { phrase: string }) {
  const { register, getValues } = useForm();
  const canSubmit = getValues("confirm") === phrase;

  return (
    <form>
      <input {...register("confirm")} />
      <button type="submit" disabled={!canSubmit}>
        Delete
      </button>
    </form>
  );
}

Typing into the field updates _formValues, so getValues("confirm") is genuinely correct the instant it is called. But nothing re-rendered, so it is never called again, and disabled keeps the value it had when the component last rendered — true, forever. Debug it with a console.log next to the read and the log prints the right string, which is what makes it cost an hour.

The fix is one word:

const { register, watch } = useForm();
const canSubmit = watch("confirm") === phrase;

What this codebase does instead

package.json here lists no form library — 13 runtime dependencies, none of them react-hook-form. Skipping React Hook Form covers that decision across login, signup, password reset and contact. It also left one honest gap in its comparison table: cross-field client validation, which it marked as "would need a client onChange handler layered on top."

src/components/molecules/SettingsForm.tsx is that handler. It backs the account-deletion card on the dashboard, and it is the same gate as the broken example above — one field compared against a known phrase to decide whether submit is live:

const [confirmText, setConfirmText] = useState("");
const canSubmit = !confirmPhrase || confirmText === confirmPhrase;
<input
  type="text"
  value={confirmText}
  onChange={(e) => setConfirmText(e.target.value)}
  placeholder={confirmPhrase}
  autoComplete="off"
/>
<input type="hidden" name="confirm" value={confirmText} />

useState is the watch of this architecture: it re-renders, so canSubmit is recomputed and disabled={pending || !canSubmit} tracks the input. Had this been written with a ref — the uncontrolled pattern every other field in the repo uses, and the one getValues is modelled on — the button would have had exactly the bug above.

Note the hidden input. Every other input in this codebase is uncontrolled and arrives in FormData by its name attribute, which is how the Server Action reads it:

// src/lib/actions/auth.ts
const email = String(formData.get("email") ?? "")
  .trim()
  .toLowerCase();

formData.get("email") is the no-library getValues("email") — a one-shot read of a value nothing subscribed to. The hidden input exists because the confirm field is the one controlled input in the form, so its value lives in React state rather than in the DOM node the browser would have serialised.

The gate is not the security boundary

One line from the molecule's own doc comment matters more than the mechanics:

the server action re-checks the same field (R-4), this is a UX gate, not the security boundary.

src/lib/actions/account.ts opens deleteAccount with the check repeated server-side:

const confirm = String(formData.get("confirm") ?? "");
if (confirm !== DELETE_CONFIRM_PHRASE) {
  return {
    ok: false,
    message: `Type "${DELETE_CONFIRM_PHRASE}" to confirm.`,
  };
}

This is the part that survives dev tools. A disabled attribute is a hint; anyone can POST to a Server Action without it. The same rule applies to getValues in a React Hook Form app: whatever you read on the client, read it again on the server. React form validation covers the allowlist shape the rest of these actions use.

Where getValues is the right call

Not re-rendering is a feature when render output does not depend on the value:

SituationUseWhy
Cross-field rule inside a validate functiongetValuesValidation already runs on change; a subscription would double the work
Reading a sibling field in an onClickgetValuesThe handler runs after render, not during it
Building the payload in onSubmitgetValues (or the data argument)One read at submit time
Enabling/disabling a controlwatchThe value drives markup
Showing a live preview or character countwatchThe value drives markup
Sending only edited fieldsgetValues(undefined, { dirtyFields: true })Narrows without a second read

The cross-field validator is the canonical case, and it is the one that makes the distinction click:

<input
  {...register("passwordConfirm", {
    validate: (value) =>
      value === getValues("password") || "Passwords do not match",
  })}
/>

No re-render is wanted here. The validator fires on change already; subscribing to password as well would re-render the component on every keystroke in a field whose value is only ever needed at validation time.

What it costs to have the API at all

Measured today in a scratch directory, not quoted from a README: react-hook-form 7.89.0, a single useForm import, bundled and minified with esbuild and react/react-dom marked external.

$ npx esbuild bundle/entry.js --bundle --minify --format=esm \
    --external:react --external:react-dom --outfile=bundle/out.js
  bundle/out.js  30.2kb

$ gzip -9c bundle/out.js | wc -c
11366

11.1 KB gzipped, 30.2 KB raw. That is the floor for useForm alone — before a resolver package, before a schema library. It buys a great deal on a long, branching, stateful form. On this storefront's four two-to-four-field forms it would have bought a getValues/watch distinction the useState above does not have to make.

Mistakes and troubleshooting

SymptomCauseFix
A button's disabled never updates, but console.log(getValues(...)) prints the right valuegetValues does not subscribe, so nothing re-rendered after the value changedUse watch for anything that drives markup
getValues() returns defaultValues instead of typed inputCalled before the form mounted — _state.mount is still falseRead it from a handler or effect that runs after mount
getValues("user.email") returns undefined on a real valuePath does not match the registered name; get() resolves dotted paths literallyLog getValues() with no arguments and copy the exact key
A field that was conditionally unmounted vanishes from getValues()shouldUnregister: true is set somewhere — on useForm or the field itself. It defaults to false, so values normally survive unmountDrop the option, or keep it and lift the value out before the field unmounts
Switching getValues to watch makes a large form sluggishEvery subscribing component now re-renders per keystrokeSubscribe to one field (watch("x")), not the whole form
Values look right on the client and arrive empty on the serverA controlled input has no name, so it is not serialised into FormDataGive it a name, or carry it in a hidden input like SettingsForm does

Frequently asked questions

Does getValues trigger a re-render? No. The implementation spreads an internal object and returns a lookup — there is no setState and no subscription in it. That is the whole difference from watch, which increments a subscriber count and subscribes to the form's state subject.

Can I use getValues in the component body? You can call it, and the value will be correct at that moment. It just will not be called again when the value changes, so anything rendered from it goes stale. In the component body, watch is almost always what you meant.

What does getValues return for a field that was never registered? undefined, via the same get(values, name) lookup used for every other path — an unregistered name is indistinguishable from a typo'd one. Calling getValues() with no arguments and reading the real keys is the fastest way to tell them apart.

Do I still need server-side validation if I validate with getValues? Yes, without exception. Everything getValues reads lives in the browser and can be edited or bypassed. This codebase re-checks its confirm phrase inside deleteAccount for exactly that reason, even though the submit button is already gated on it.

Is there a no-library equivalent? formData.get("name") inside a Server Action. Same semantics — a one-shot read with no subscription — which is why the four forms in this repo never needed the distinction this post is about.

Templates in this post

ASoc Mind is an AI marketing-solutions site with image-AI services, a projects grid, team and achievements — its contact and brief-request forms are the multi-field shape where a cross-field validate rule earns its keep. ASoc Momentum is an AI-consulting agency site built around case studies and a contact funnel, the two-to-four-field form this codebase serves with FormData alone. ASoc Neuron markets a neural-networks platform with six capability blocks and 3-tier pricing, where a plan-selection field and a signup field have to agree before submit — the gate SettingsForm implements above.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates.

Keep reading

Tutorial8 min read

React JSX: The Rules That Only Break the Production Build

JSX compiles to function calls, not HTML. Where the two diverge, why a lowercase SVG tag survives dev and fails next build, and the key rule behind phantom state bugs.

Read more
Tutorial10 min read

React Landing Pages: Half the HTML Isn't the Page

This storefront's home page prerenders to 436 KB, and 52.1% of that is a serialized copy of the render, not the page. What actually reaches a crawler before any JavaScript runs.

Read more