Skip to content

Instantly share code, notes, and snippets.

@levchenkod
Created July 18, 2025 17:08
Show Gist options
  • Save levchenkod/3c7e3d8dc0e2d34747bd2b8848839c38 to your computer and use it in GitHub Desktop.
Save levchenkod/3c7e3d8dc0e2d34747bd2b8848839c38 to your computer and use it in GitHub Desktop.
useResetFormAsync - React Hook Form async reset
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, act } from '#tests/utils';
import { FormProvider, useForm } from 'react-hook-form';
import { useResetFormAsync } from './useResetFormAsync';
import React, { useEffect } from 'react';
vi.useFakeTimers();
// Mock requestAnimationFrame to work with fake timers
vi.spyOn(global, 'requestAnimationFrame').mockImplementation((cb) => {
setTimeout(cb, 16);
return 1; // return a number as expected by the interface
});
interface PropertyFormValues {
propertyTitle: string;
};
// Child component that uses the hook inside the FormProvider context
const FormChildComponent = ({
onReady,
}: {
onReady: (resetAsync: ReturnType<typeof useResetFormAsync>) => void;
}) => {
const resetAsync = useResetFormAsync();
useEffect(() => {
onReady(resetAsync);
}, [resetAsync, onReady]);
return <form />;
};
const PropertyFormWrapper = ({
onReady,
}: {
onReady: (
resetAsync: ReturnType<typeof useResetFormAsync>,
methods: ReturnType<typeof useForm<PropertyFormValues>>
) => void;
}) => {
const methods = useForm<PropertyFormValues>({
defaultValues: { propertyTitle: 'Sunny Villa' },
});
const handleChildReady = (resetAsync: ReturnType<typeof useResetFormAsync>) => {
onReady(resetAsync, methods);
};
return (
<FormProvider {...methods}>
<FormChildComponent onReady={handleChildReady} />
</FormProvider>
);
};
describe('useResetFormAsync', () => {
let resetPropertyForm: ReturnType<typeof useResetFormAsync>;
let propertyFormMethods: ReturnType<typeof useForm<PropertyFormValues>>;
beforeEach(() => {
render(
<PropertyFormWrapper
onReady={(resetFn, methods) => {
resetPropertyForm = resetFn;
propertyFormMethods = methods;
}}
/>
);
});
afterEach(() => {
vi.clearAllTimers();
vi.clearAllMocks();
});
it('resolves once property form is clean again', async () => {
// Make the form dirty first
act(() => {
propertyFormMethods.setValue('propertyTitle', 'Cozy Cottage');
});
let isResolved = false;
const resetPromise = resetPropertyForm({ propertyTitle: 'Sunny Villa' }).then(() => {
isResolved = true;
});
// Wait for the reset and requestAnimationFrame to process
await act(async () => {
// Advance timers to trigger the requestAnimationFrame
for (let i = 0; i < 5; i++) {
vi.advanceTimersToNextTimer();
}
});
await resetPromise;
expect(isResolved).toBeTruthy();
});
it('throws if keepDirty is used on property form reset', () => {
expect(() =>
resetPropertyForm(
{ propertyTitle: 'Sunny Villa' },
{ keepDirty: true }
)
).toThrow(/Do not use it with the `keepDirty` option/);
});
});
import { useRef, useEffect } from 'react';
import { useFormContext, FieldValues, KeepStateOptions } from 'react-hook-form';
/**
* A hook to reset the form asynchronously.
*
* Context:
* Form reset takes an unpredictable time to complete as it depends on the form size and complexity.
* This hook is a workaround to ensure that the next step will be executed only after the form has been reset.
*
* When to use:
* - You want to reset the form and do the next step only AFTER the form is reset
*
* When NOT to use it:
* - You just want to reset the form. Use `form.reset()` instead.
* - The form is most likely not dirty, but you want to swap the form values.
* - You want to reset the form but keep the dirty state.
* Use `form.reset(newInitialValues, { keepDirty: true })` instead.
*
* @example
* const resetAsync = useResetFormAsync();
*
* resetAsync().then(() => {
* console.log('Form reset');
* });
*
* OR
*
* resetAsync(newInitialValues, resetOptions); // These params are directly passed to the form.reset method
*
* WARNING:
* This util checks against the isDirty state.
* DO NOT use it with the `keepDirty` option
*
* resetAsync(newInitialValues, { keepDirty: true });
*
* This will lead to an infinite loop and will throw an error
*
*/
export const useResetFormAsync = () => {
const form = useFormContext();
const { isDirty } = form.formState;
const isDirtyRef = useRef(isDirty);
useEffect(() => {
isDirtyRef.current = isDirty;
}, [isDirty]);
const resetAsync = (
values?: FieldValues,
options?: KeepStateOptions
): Promise<void> => {
if (options?.keepDirty) {
throw new Error('useResetFormAsync - Do not use it with the `keepDirty` option. Reed more in the hook comments.');
}
return new Promise((resolve, reject) => {
let tick = 0;
form.reset(values, options);
const check = () => {
tick++;
if (isDirtyRef.current) {
if (tick > 1000) {
reject(new Error('Form is still dirty after 1000 ticks'));
} else {
requestAnimationFrame(check);
}
} else {
resolve();
}
};
check();
});
};
return resetAsync;
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment