Fix : ensure the isValidating is set when trigger is called programma… by Karan7687 · Pull Request #13241 · react-hook-form/react-hook-form · GitHub
Skip to content

Fix : ensure the isValidating is set when trigger is called programma…#13241

Open
Karan7687 wants to merge 3 commits intoreact-hook-form:masterfrom
Karan7687:fix/trigger-isvalidating
Open

Fix : ensure the isValidating is set when trigger is called programma…#13241
Karan7687 wants to merge 3 commits intoreact-hook-form:masterfrom
Karan7687:fix/trigger-isvalidating

Conversation

@Karan7687
Copy link
Copy Markdown

Fixes an issue where calling trigger() asynchronously (e.g. via setTimeout or useEffect)
does not toggle isValidating to true.

This ensures isValidating is set when trigger() starts and reliably reset after validation.

Closes #13218

@bluebill1049
Copy link
Copy Markdown
Member

@Karan7687
Copy link
Copy Markdown
Author

Updated the Test to avoid some timing issues and still covering the async trigger behavior.
Tests are passing now, thanks for the suggestion!

Comment thread src/__tests__/useForm/formState.test.tsx
Comment thread src/__tests__/useForm/formState.test.tsx
Comment thread src/logic/createFormControl.ts
@Karan7687
Copy link
Copy Markdown
Author

I have addressed the feedback,added the coverage for the async trigger case and merged the latest master.

@tswirski
Copy link
Copy Markdown

Because what we need is to have valid isValidating flag after trigger called programmatically for entire form - We've work-around that in following way.

` const formStateProxyFactory = useRef(() => {
return {
get(obj, prop) {
if (prop === 'isValidating') {
return selfIsValidating.current;
}
return obj[prop];
},
};
});

const formMethodsProxyFactory = useRef(() => {
let triggerFn;

const triggerProxy = (name: string, options: any): Promise<any> => {
  if (name) {
    return triggerFn?.(name, options);
  }
  return new Promise(resolve => {
    selfIsValidating.current = true;
    requestAnimationFrame(() =>
      flushSync(async () => {
        const result = await triggerFn(name, options);
        selfIsValidating.current = false;
        resolve(result);
      })
    );
  });
};

return {
  get(obj, prop) {
    if (prop === 'trigger') {
      triggerFn = obj[prop];
      return triggerProxy;
    }

    if (prop === 'formState') {
      if (!formStateProxy.current) {
        formStateProxy.current = new Proxy(obj[prop], formStateProxyFactory.current());
      }
      return formStateProxy.current;
    }

    return obj[prop];
  },
};

});

useEffect(() => {
selfIsValidating.current = methods.formState.isValidating;
}, [methods.formState.isValidating]);

useEffect(() => {
formStateProxy.current = null;
}, [methods.formState]);

useEffect(() => {
setFormMethodsProxy(new Proxy(methods, formMethodsProxyFactory.current()));
}, [methods]);
`

It doesn't throw error for flushSync, and it works as we expect it to work (so far).

@Karan7687
Copy link
Copy Markdown
Author

Thanks for sharing this, that is a clever workaround.

my intention with this change was to just make trigger() work consistently at the core level.Consumers should not have to rely on Proxies,flushSync or RAF to see the correct isValidating state.

Since trigger() already returns a promise and starts async validation internally settings isValidating at the beginning of that process seemed like a reasonable and predictable default.The test i added just confirms that behavior.

i am open to changing it if you think this should be outside the core i am happy to work together.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes formState.isValidating not reliably toggling to true when trigger() is invoked asynchronously (e.g., via setTimeout / useEffect), and adds a regression test.

Changes:

  • Wraps trigger() validation logic with an isValidating toggle.
  • Ensures isValidating is reset via finally.
  • Adds a test intended to cover async trigger() behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 6 comments.

File Description
src/logic/createFormControl.ts Adds _updateIsValidating calls around trigger() execution.
src/__tests__/useForm/formState.test.tsx Adds a test for async trigger() and related timer handling.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +903 to +910
// works for async triggr
_updateIsValidating(fieldNames, true);

isValid = isEmptyObject(errors);
validationResult = name
? !fieldNames.some((name) => get(errors, name))
: isValid;
} else if (name) {
validationResult = (
await Promise.all(
fieldNames.map(async (fieldName) => {
const field = get(_fields, fieldName);
return await executeBuiltInValidation(
field && field._f ? { [fieldName]: field } : field,
);
}),
)
).every(Boolean);
!(!validationResult && !_formState.isValid) && _setValid();
} else {
validationResult = isValid = await executeBuiltInValidation(_fields);
}
try {
if (_options.resolver) {
const errors = await executeSchemaAndUpdateState(
isUndefined(name) ? name : fieldNames,
);
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In trigger, _updateIsValidating(fieldNames, true) runs even when using a resolver, but resolver validation already toggles validating state via _runSchema()/executeSchemaAndUpdateState(). This will emit duplicate isValidating updates (true twice, false twice) and will likely break batching expectations (e.g. src/__tests__/useForm/resolver.test.tsx asserts only 2 emissions for trigger('test')). Consider only doing the manual _updateIsValidating toggling for the non-resolver path, or otherwise dedupe the resolver-driven updates so isValidating only transitions once per trigger call.

Copilot uses AI. Check for mistakes.
Comment on lines 900 to +954
let validationResult;
const fieldNames = convertToArrayPayload(name) as InternalFieldName[];

if (_options.resolver) {
const errors = await executeSchemaAndUpdateState(
isUndefined(name) ? name : fieldNames,
);
// works for async triggr
_updateIsValidating(fieldNames, true);

isValid = isEmptyObject(errors);
validationResult = name
? !fieldNames.some((name) => get(errors, name))
: isValid;
} else if (name) {
validationResult = (
await Promise.all(
fieldNames.map(async (fieldName) => {
const field = get(_fields, fieldName);
return await executeBuiltInValidation(
field && field._f ? { [fieldName]: field } : field,
);
}),
)
).every(Boolean);
!(!validationResult && !_formState.isValid) && _setValid();
} else {
validationResult = isValid = await executeBuiltInValidation(_fields);
}
try {
if (_options.resolver) {
const errors = await executeSchemaAndUpdateState(
isUndefined(name) ? name : fieldNames,
);

_subjects.state.next({
...(!isString(name) ||
((_proxyFormState.isValid || _proxySubscribeFormState.isValid) &&
isValid !== _formState.isValid)
? {}
: { name }),
...(_options.resolver || !name ? { isValid } : {}),
errors: _formState.errors,
});
isValid = isEmptyObject(errors);
validationResult = name
? !fieldNames.some((n) => get(errors, n))
: isValid;
} else if (name) {
validationResult = (
await Promise.all(
fieldNames.map(async (fieldName) => {
const field = get(_fields, fieldName);
return await executeBuiltInValidation(
field && field._f ? { [fieldName]: field } : field,
);
}),
)
).every(Boolean);

options.shouldFocus &&
!validationResult &&
iterateFieldsByAction(
_fields,
_focusInput,
name ? fieldNames : _names.mount,
);
!(!validationResult && !_formState.isValid) && _setValid();
} else {
validationResult = isValid = await executeBuiltInValidation(_fields);
}

return validationResult;
_subjects.state.next({
...(!isString(name) ||
((_proxyFormState.isValid || _proxySubscribeFormState.isValid) &&
isValid !== _formState.isValid)
? {}
: { name }),
...(_options.resolver || !name ? { isValid } : {}),
errors: _formState.errors,
});

options.shouldFocus &&
!validationResult &&
iterateFieldsByAction(
_fields,
_focusInput,
name ? fieldNames : _names.mount,
);

return validationResult;
} finally {
_updateIsValidating(fieldNames);
}
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fieldNames is computed via convertToArrayPayload(name), which turns undefined into [undefined]. Passing that into _updateIsValidating(fieldNames, true) is a no-op (names are skipped when falsy), so trigger() with no args still won’t set isValidating at start. In addition, the finally block clears only fieldNames, which also becomes a no-op for trigger() without a name and can leave validatingFields stuck if a resolver throws before it gets cleared elsewhere. Use name ? fieldNames : undefined (or _names.mount) consistently for both set and clear so the full-form trigger path is handled correctly.

Copilot uses AI. Check for mistakes.
const errors = await executeSchemaAndUpdateState(
isUndefined(name) ? name : fieldNames,
);
// works for async triggr
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The inline comment // works for async triggr has a spelling error and doesn’t add much beyond what the code already expresses. Please either remove it or replace it with a clear, correctly spelled explanation of the underlying behavior being fixed.

Suggested change
// works for async triggr
// Mark these fields as validating before awaiting async validation; the state is cleared in finally.

Copilot uses AI. Check for mistakes.
Comment on lines +277 to +308
const resolver = jest.fn(async () => ({
values: {},
errors: {},
}));

const { result } = renderHook(() =>
useForm({
resolver,
}),
);

expect(result.current.formState.isValidating).toBe(false);

act(() => {
setTimeout(() => {
result.current.trigger();
}, 100);
});

act(() => {
jest.advanceTimersByTime(100);
});

// wait until validation completes
await waitFor(() =>
expect(result.current.formState.isValidating).toBe(false),
);

// resolver must have been called
expect(resolver).toHaveBeenCalled();

jest.useRealTimers();
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test never asserts that isValidating becomes true (it only checks false before and after), so it can pass even if the regression isn’t fixed. To make it meaningful, assert the intermediate true state (e.g. use a resolver that resolves after a timeout, then check isValidating === true after starting trigger() but before advancing timers to complete validation).

Suggested change
const resolver = jest.fn(async () => ({
values: {},
errors: {},
}));
const { result } = renderHook(() =>
useForm({
resolver,
}),
);
expect(result.current.formState.isValidating).toBe(false);
act(() => {
setTimeout(() => {
result.current.trigger();
}, 100);
});
act(() => {
jest.advanceTimersByTime(100);
});
// wait until validation completes
await waitFor(() =>
expect(result.current.formState.isValidating).toBe(false),
);
// resolver must have been called
expect(resolver).toHaveBeenCalled();
jest.useRealTimers();
try {
const resolver = jest.fn(
() =>
new Promise((resolve) => {
setTimeout(() => {
resolve({
values: {},
errors: {},
});
}, 100);
}),
);
const { result } = renderHook(() =>
useForm({
resolver,
}),
);
expect(result.current.formState.isValidating).toBe(false);
let triggerPromise: Promise<boolean> | undefined;
act(() => {
triggerPromise = result.current.trigger();
});
await waitFor(() =>
expect(result.current.formState.isValidating).toBe(true),
);
expect(resolver).toHaveBeenCalled();
act(() => {
jest.advanceTimersByTime(100);
});
await triggerPromise!;
await waitFor(() =>
expect(result.current.formState.isValidating).toBe(false),
);
} finally {
jest.useRealTimers();
}

Copilot uses AI. Check for mistakes.
Comment on lines +273 to 312
//added
it('should toggle isValidating when trigger is called asynchronously', async () => {
jest.useFakeTimers();

const resolver = jest.fn(async () => ({
values: {},
errors: {},
}));

const { result } = renderHook(() =>
useForm({
resolver,
}),
);

expect(result.current.formState.isValidating).toBe(false);

act(() => {
setTimeout(() => {
result.current.trigger();
}, 100);
});

act(() => {
jest.advanceTimersByTime(100);
});

// wait until validation completes
await waitFor(() =>
expect(result.current.formState.isValidating).toBe(false),
);

// resolver must have been called
expect(resolver).toHaveBeenCalled();

jest.useRealTimers();
});

//here
it('should be a proxy object that returns undefined for unknown properties', () => {
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove the leftover inline comments like //added / //here. They don’t describe intent or behavior and add noise to the test file.

Copilot uses AI. Check for mistakes.
Comment on lines +277 to +308
const resolver = jest.fn(async () => ({
values: {},
errors: {},
}));

const { result } = renderHook(() =>
useForm({
resolver,
}),
);

expect(result.current.formState.isValidating).toBe(false);

act(() => {
setTimeout(() => {
result.current.trigger();
}, 100);
});

act(() => {
jest.advanceTimersByTime(100);
});

// wait until validation completes
await waitFor(() =>
expect(result.current.formState.isValidating).toBe(false),
);

// resolver must have been called
expect(resolver).toHaveBeenCalled();

jest.useRealTimers();
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

jest.useRealTimers() is called only at the end of the test; if an assertion throws before that line, fake timers can leak into later tests. Wrap the body in try/finally (or use afterEach(() => jest.useRealTimers())) to guarantee timer restoration even on failure.

Suggested change

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

issue: methods.trigger() wont toggle "isValidating" to true when called programaticaly

4 participants