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.
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
getValues | watch | |
|---|---|---|
| Returns current values | Yes | Yes |
| Re-renders on change | No | Yes |
| Subscribes to the form | No | Yes |
| Safe in render to drive UI | No — the value is correct but stale on screen | Yes |
| Right place to call it | Event handlers, validate functions, onSubmit | Component body, when render output depends on a field |
| Cost per keystroke | Zero | A 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:
| Situation | Use | Why |
|---|---|---|
Cross-field rule inside a validate function | getValues | Validation already runs on change; a subscription would double the work |
Reading a sibling field in an onClick | getValues | The handler runs after render, not during it |
Building the payload in onSubmit | getValues (or the data argument) | One read at submit time |
| Enabling/disabling a control | watch | The value drives markup |
| Showing a live preview or character count | watch | The value drives markup |
| Sending only edited fields | getValues(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
| Symptom | Cause | Fix |
|---|---|---|
A button's disabled never updates, but console.log(getValues(...)) prints the right value | getValues does not subscribe, so nothing re-rendered after the value changed | Use watch for anything that drives markup |
getValues() returns defaultValues instead of typed input | Called before the form mounted — _state.mount is still false | Read it from a handler or effect that runs after mount |
getValues("user.email") returns undefined on a real value | Path does not match the registered name; get() resolves dotted paths literally | Log 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 unmount | Drop the option, or keep it and lift the value out before the field unmounts |
Switching getValues to watch makes a large form sluggish | Every subscribing component now re-renders per keystroke | Subscribe to one field (watch("x")), not the whole form |
| Values look right on the client and arrive empty on the server | A controlled input has no name, so it is not serialised into FormData | Give 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.
