From b61dfe4b866febe79c925967fcb2e3ff9f91284f Mon Sep 17 00:00:00 2001 From: UgoxyFavour Date: Tue, 28 Apr 2026 02:29:53 +0100 Subject: [PATCH] I implemented missin load state for mutation --- package.json | 10 - src/app/(auth)/login/page.tsx | 45 ++-- src/app/(auth)/signup/page.tsx | 45 ++-- src/components/forms/DynamicFormBuilder.tsx | 68 ++--- src/components/forms/FormWizardController.tsx | 53 ++-- src/components/forms/SubmitButton.tsx | 94 +++++++ src/components/profile/ProfileEditForm.tsx | 17 +- src/hooks/__tests__/useMutation.test.tsx | 244 ++++++++++++++++++ src/hooks/useMutation.tsx | 147 +++++++++++ 9 files changed, 614 insertions(+), 109 deletions(-) create mode 100644 src/components/forms/SubmitButton.tsx create mode 100644 src/hooks/__tests__/useMutation.test.tsx create mode 100644 src/hooks/useMutation.tsx diff --git a/package.json b/package.json index 46972eb6..34e5a443 100644 --- a/package.json +++ b/package.json @@ -39,10 +39,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^3.6.0", -<<<<<<< HEAD -======= "dompurify": "^3.2.4", ->>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "framer-motion": "^12.23.0", "idb": "^8.0.0", "lucide-react": "^0.462.0", @@ -57,20 +54,13 @@ "react-hot-toast": "^2.6.0", "react-icons": "^5.5.0", "react-intersection-observer": "^10.0.3", -<<<<<<< HEAD -======= "react-virtualized-auto-sizer": "^1.0.7", "react-window": "^1.8.9", ->>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "recharts": "^2.15.4", "socket.io-client": "^4.8.3", "tailwind-merge": "^2.6.0", "web-vitals": "^4.2.4", "workbox-webpack-plugin": "^7.0.0", -<<<<<<< HEAD - "dompurify": "^3.2.4", -======= ->>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "zod": "^3.25.75", "zustand": "^5.0.10" }, diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index ab7ee2ea..f85b6c45 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -9,13 +9,13 @@ import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { loginSchema, LoginFormData } from '../../lib/validationSchemas'; import { FormError, FieldError } from '../../../components/forms/FormError'; +import { SubmitButton } from '../../../components/forms/SubmitButton'; +import { useMutation } from '../../../hooks/useMutation'; import { apiClient } from '@/lib/api'; import { parseApiError } from '@/utils/error-handler'; export default function LoginPage() { - const [isLoading, setIsLoading] = useState(false); const [showPassword, setShowPassword] = useState(false); - const [apiError, setApiError] = useState(''); const [successMessage, setSuccessMessage] = useState(''); const router = useRouter(); @@ -27,20 +27,20 @@ export default function LoginPage() { resolver: zodResolver(loginSchema), }); - const onSubmit = async (data: LoginFormData) => { - setIsLoading(true); - setApiError(''); - setSuccessMessage(''); - - try { + const loginMutation = useMutation( + async (data: LoginFormData) => { await apiClient.post('/api/auth/login', data); - setSuccessMessage('Login successful! Redirecting...'); - setTimeout(() => router.push('/dashboard'), 1500); - } catch (error) { - setApiError(parseApiError(error).userMessage); - } finally { - setIsLoading(false); - } + }, + { + onSuccess: () => { + setSuccessMessage('Login successful! Redirecting...'); + setTimeout(() => router.push('/dashboard'), 1500); + }, + }, + ); + + const onSubmit = async (data: LoginFormData) => { + await loginMutation.mutate(data); }; return ( @@ -131,7 +131,10 @@ export default function LoginPage() { - + {successMessage && ( )} - + Sign in + {/* Sign up link */} diff --git a/src/app/(auth)/signup/page.tsx b/src/app/(auth)/signup/page.tsx index 01f3a34b..fc55f829 100644 --- a/src/app/(auth)/signup/page.tsx +++ b/src/app/(auth)/signup/page.tsx @@ -9,13 +9,13 @@ import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { signupSchema, SignupFormData } from '../../lib/validationSchemas'; import { FormError, FieldError } from '../../../components/forms/FormError'; +import { SubmitButton } from '../../../components/forms/SubmitButton'; +import { useMutation } from '../../../hooks/useMutation'; import { apiClient } from '@/lib/api'; import { parseApiError } from '@/utils/error-handler'; export default function SignupPage() { - const [isLoading, setIsLoading] = useState(false); const [showPassword, setShowPassword] = useState(false); - const [apiError, setApiError] = useState(''); const [successMessage, setSuccessMessage] = useState(''); const router = useRouter(); @@ -27,20 +27,20 @@ export default function SignupPage() { resolver: zodResolver(signupSchema), }); - const onSubmit = async (data: SignupFormData) => { - setIsLoading(true); - setApiError(''); - setSuccessMessage(''); - - try { + const signupMutation = useMutation( + async (data: SignupFormData) => { await apiClient.post('/api/auth/signup', data); - setSuccessMessage('Account created successfully! Redirecting...'); - setTimeout(() => router.push('/dashboard'), 1500); - } catch (error) { - setApiError(parseApiError(error).userMessage); - } finally { - setIsLoading(false); - } + }, + { + onSuccess: () => { + setSuccessMessage('Account created successfully! Redirecting...'); + setTimeout(() => router.push('/dashboard'), 1500); + }, + }, + ); + + const onSubmit = async (data: SignupFormData) => { + await signupMutation.mutate(data); }; return ( @@ -133,7 +133,10 @@ export default function SignupPage() { - + {successMessage && ( )} - + Create account + {/* Sign in link */} diff --git a/src/components/forms/DynamicFormBuilder.tsx b/src/components/forms/DynamicFormBuilder.tsx index 14b64e4c..3782298c 100644 --- a/src/components/forms/DynamicFormBuilder.tsx +++ b/src/components/forms/DynamicFormBuilder.tsx @@ -12,6 +12,8 @@ import { FormStateManager } from '@/form-management/state/form-state-manager'; import { ValidationEngineImpl } from '@/form-management/validation/validation-engine'; import { AutoSaveManagerImpl } from '@/form-management/auto-save/auto-save-manager'; import { useNotification } from '@/hooks/use-notification'; +import { useMutation } from '@/hooks/useMutation'; +import { SubmitButton } from '@/components/forms/SubmitButton'; interface DynamicFormBuilderProps { config: FormConfiguration | string; @@ -38,6 +40,23 @@ export const DynamicFormBuilder: React.FC = ({ const [saveStatus, setSaveStatus] = useState('idle'); const { success, error: notifyError } = useNotification(); + // ── Mutation ───────────────────────────────────────────────────────────── + const submitMutation = useMutation( + async (values: Record) => { + if (onSubmit) { + await onSubmit(values); + } + }, + { + onSuccess: () => { + success('Form submitted successfully!'); + }, + onError: () => { + notifyError('Submission failed. Please try again.'); + }, + }, + ); + // Parse configuration useEffect(() => { const parser = new FormConfigurationParser(); @@ -122,36 +141,21 @@ export const DynamicFormBuilder: React.FC = ({ const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!formConfig) return; - - stateManager.setSubmitting(true); - const toastId = success('Submitting...'); - - try { - // Validate entire form - const validationResult = await validationEngine.validateForm(stateManager.getState()); + if (!formConfig || submitMutation.isLoading) return; - if (!validationResult.isValid) { - notifyError('Validation failed. Please check the required fields.'); - stateManager.setSubmitting(false); - return; - } + // Validate entire form before delegating to mutation + const validationResult = await validationEngine.validateForm(stateManager.getState()); - // Submit form - if (onSubmit) { - await onSubmit(formState.values); - success('Form submitted successfully!'); - } + if (!validationResult.isValid) { + notifyError('Validation failed. Please check the required fields.'); + return; + } - // Clear draft after successful submission - if (autoSave) { - await autoSaveManager.clearDraft(formConfig.id); - } + await submitMutation.mutate(formState.values); - stateManager.completeSubmission(true); - } catch (error) { - notifyError('Submission failed. Please try again.'); - stateManager.completeSubmission(false); + // Clear draft after successful submission + if (autoSave && submitMutation.isSuccess) { + await autoSaveManager.clearDraft(formConfig.id); } }; @@ -196,6 +200,7 @@ export const DynamicFormBuilder: React.FC = ({ onBlur: () => handleFieldBlur(field.id), placeholder: field.placeholder, required: field.required, + disabled: submitMutation.isLoading, className: 'form-input', }; @@ -257,13 +262,14 @@ export const DynamicFormBuilder: React.FC = ({
- + Submit +
); diff --git a/src/components/forms/FormWizardController.tsx b/src/components/forms/FormWizardController.tsx index bad3284d..eca81627 100644 --- a/src/components/forms/FormWizardController.tsx +++ b/src/components/forms/FormWizardController.tsx @@ -10,6 +10,8 @@ import { WizardStep, WizardProgress, FormState } from '@/form-management/types/c import { FormStateManager } from '@/form-management/state/form-state-manager'; import { ValidationEngineImpl } from '@/form-management/validation/validation-engine'; import { useNotification } from '@/hooks/use-notification'; +import { useMutation } from '@/hooks/useMutation'; +import { SubmitButton } from '@/components/forms/SubmitButton'; interface FormWizardControllerProps { steps: WizardStep[]; @@ -42,6 +44,23 @@ export const FormWizardController: React.FC = ({ const currentStep = steps[currentStepIndex]; + // ── Submission mutation ──────────────────────────────────────────────────── + const completeMutation = useMutation( + async (values: Record) => { + if (onComplete) { + await onComplete(values); + } + }, + { + onSuccess: () => { + success('Form submitted successfully!'); + }, + onError: (err) => { + error(err instanceof Error ? err.message : 'Failed to complete the form.'); + }, + }, + ); + const progress: WizardProgress = { currentStep: currentStepIndex, totalSteps: steps.length, @@ -62,7 +81,6 @@ export const FormWizardController: React.FC = ({ setIsValidating(true); try { - // Validate all fields in current step const stepFields = currentStep.fields; let allValid = true; @@ -96,12 +114,10 @@ export const FormWizardController: React.FC = ({ return; } - // Mark current step as completed if (!completedSteps.includes(currentStepIndex)) { setCompletedSteps([...completedSteps, currentStepIndex]); } - // Check for conditional routing if (currentStep.conditionalNext) { const nextStepIndex = currentStep.conditionalNext(formState); setCurrentStepIndex(nextStepIndex); @@ -117,7 +133,6 @@ export const FormWizardController: React.FC = ({ const handleGoToStep = async (stepIndex: number) => { if (!allowNonLinearNavigation) { - // Only allow navigation to completed steps if (!completedSteps.includes(stepIndex) && stepIndex !== currentStepIndex) { console.warn('Cannot navigate to incomplete step'); return; @@ -136,14 +151,7 @@ export const FormWizardController: React.FC = ({ const isValid = await validateCurrentStep(); if (!isValid) return; - if (onComplete) { - try { - await onComplete(formState.values); - success('Form submitted successfully!'); - } catch (err) { - error(err instanceof Error ? err.message : 'Failed to complete the form.'); - } - } + await completeMutation.mutate(formState.values); }; const isStepAccessible = (stepIndex: number): boolean => { @@ -169,6 +177,7 @@ export const FormWizardController: React.FC = ({ onPrevious={handlePrevious} onComplete={handleComplete} isValidating={isValidating} + isSubmitting={completeMutation.isLoading} isLastStep={currentStepIndex === steps.length - 1} /> @@ -220,6 +229,8 @@ interface WizardNavigationProps { onPrevious: () => void; onComplete: () => void; isValidating: boolean; + /** True while the final submission mutation is in-flight. */ + isSubmitting: boolean; isLastStep: boolean; } @@ -229,11 +240,14 @@ const WizardNavigation: React.FC = ({ onPrevious, onComplete, isValidating, + isSubmitting, isLastStep, }) => { + const isBusy = isValidating || isSubmitting; + return (
- @@ -242,13 +256,20 @@ const WizardNavigation: React.FC = ({
{isLastStep ? ( - + ) : ( + ); +}; + +export default SubmitButton; diff --git a/src/components/profile/ProfileEditForm.tsx b/src/components/profile/ProfileEditForm.tsx index 887e944b..5276ffa0 100644 --- a/src/components/profile/ProfileEditForm.tsx +++ b/src/components/profile/ProfileEditForm.tsx @@ -8,6 +8,7 @@ import { User, Mail, FileText } from 'lucide-react'; import ImageUploader from '../shared/ImageUploader'; import PreferencesSection from './PreferencesSection'; import { FormInput } from '../forms/FormInput'; +import { SubmitButton } from '../forms/SubmitButton'; import { useStore } from '../../store/stateManager'; // Schema definition @@ -123,17 +124,13 @@ export default function ProfileEditForm() { > Cancel - + Save Changes + diff --git a/src/hooks/__tests__/useMutation.test.tsx b/src/hooks/__tests__/useMutation.test.tsx new file mode 100644 index 00000000..15743de8 --- /dev/null +++ b/src/hooks/__tests__/useMutation.test.tsx @@ -0,0 +1,244 @@ +/** + * useMutation – unit tests + * + * Covers: + * - Initial idle state + * - Loading toggles true → false on success + * - Data stored after success + * - Error stored after rejection; isError set + * - Double-submission prevention (concurrent call is a no-op) + * - onSuccess / onError / onSettled callbacks + * - reset() returns to idle state + * - mutateAsync rejects so callers can catch + */ + +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { useMutation } from '../useMutation'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** Creates a deferred promise that can be resolved/rejected from outside. */ +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('useMutation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ── Initial state ────────────────────────────────────────────────────────── + + it('starts in idle state', () => { + const { result } = renderHook(() => useMutation(vi.fn())); + + expect(result.current.isLoading).toBe(false); + expect(result.current.isSuccess).toBe(false); + expect(result.current.isError).toBe(false); + expect(result.current.data).toBeNull(); + expect(result.current.error).toBeNull(); + }); + + // ── Success path ─────────────────────────────────────────────────────────── + + it('sets isLoading=true while in-flight, then resolves with data', async () => { + const { promise, resolve } = deferred(); + const mutationFn = vi.fn(() => promise); + + const { result } = renderHook(() => useMutation(mutationFn)); + + // Kick off without awaiting yet + act(() => { + result.current.mutate(undefined as any); + }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.isSuccess).toBe(false); + + // Resolve the promise + await act(async () => { + resolve('hello'); + await promise; + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.isSuccess).toBe(true); + expect(result.current.data).toBe('hello'); + }); + + it('calls onSuccess with data and variables', async () => { + const onSuccess = vi.fn(); + const mutationFn = vi.fn().mockResolvedValue(42); + + const { result } = renderHook(() => useMutation(mutationFn, { onSuccess })); + + await act(async () => { + await result.current.mutateAsync('input' as any); + }); + + expect(onSuccess).toHaveBeenCalledWith(42, 'input'); + }); + + it('calls onSettled after success', async () => { + const onSettled = vi.fn(); + const mutationFn = vi.fn().mockResolvedValue('ok'); + + const { result } = renderHook(() => useMutation(mutationFn, { onSettled })); + + await act(async () => { + await result.current.mutateAsync(undefined as any); + }); + + expect(onSettled).toHaveBeenCalledWith('ok', null, undefined); + }); + + // ── Error path ───────────────────────────────────────────────────────────── + + it('sets isError=true and stores error on rejection', async () => { + const err = new Error('boom'); + const mutationFn = vi.fn().mockRejectedValue(err); + + const { result } = renderHook(() => useMutation(mutationFn)); + + await act(async () => { + await result.current.mutate(undefined as any); + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.isSuccess).toBe(false); + expect(result.current.isError).toBe(true); + expect(result.current.error).toBe(err); + expect(result.current.data).toBeNull(); + }); + + it('calls onError with the error and variables', async () => { + const onError = vi.fn(); + const err = new Error('oops'); + const mutationFn = vi.fn().mockRejectedValue(err); + + const { result } = renderHook(() => useMutation(mutationFn, { onError })); + + await act(async () => { + await result.current.mutate('payload' as any); + }); + + expect(onError).toHaveBeenCalledWith(err, 'payload'); + }); + + it('calls onSettled after error', async () => { + const onSettled = vi.fn(); + const err = new Error('fail'); + const mutationFn = vi.fn().mockRejectedValue(err); + + const { result } = renderHook(() => useMutation(mutationFn, { onSettled })); + + await act(async () => { + await result.current.mutate(undefined as any); + }); + + expect(onSettled).toHaveBeenCalledWith(null, err, undefined); + }); + + it('mutateAsync rejects so callers can catch', async () => { + const err = new Error('reject me'); + const mutationFn = vi.fn().mockRejectedValue(err); + + const { result } = renderHook(() => useMutation(mutationFn)); + + await expect( + act(() => result.current.mutateAsync(undefined as any)), + ).rejects.toThrow('reject me'); + }); + + // ── Double-submission prevention ─────────────────────────────────────────── + + it('ignores concurrent mutate calls while in-flight', async () => { + const { promise, resolve } = deferred(); + const mutationFn = vi.fn(() => promise); + + const { result } = renderHook(() => useMutation(mutationFn)); + + // First call + act(() => { + result.current.mutate(undefined as any); + }); + + // Second call – should be a no-op because inFlightRef is true + act(() => { + result.current.mutate(undefined as any); + }); + + await act(async () => { + resolve('done'); + await promise; + }); + + // mutationFn must only have been called once + expect(mutationFn).toHaveBeenCalledTimes(1); + expect(result.current.isSuccess).toBe(true); + }); + + // ── reset ────────────────────────────────────────────────────────────────── + + it('reset() returns state to idle after success', async () => { + const mutationFn = vi.fn().mockResolvedValue('result'); + const { result } = renderHook(() => useMutation(mutationFn)); + + await act(async () => { + await result.current.mutateAsync(undefined as any); + }); + + expect(result.current.isSuccess).toBe(true); + + act(() => { + result.current.reset(); + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.isSuccess).toBe(false); + expect(result.current.isError).toBe(false); + expect(result.current.data).toBeNull(); + expect(result.current.error).toBeNull(); + }); + + it('reset() clears error state', async () => { + const mutationFn = vi.fn().mockRejectedValue(new Error('x')); + const { result } = renderHook(() => useMutation(mutationFn)); + + await act(async () => { + await result.current.mutate(undefined as any); + }); + + expect(result.current.isError).toBe(true); + + act(() => { + result.current.reset(); + }); + + expect(result.current.isError).toBe(false); + expect(result.current.error).toBeNull(); + }); + + // ── Non-Error rejection ──────────────────────────────────────────────────── + + it('wraps non-Error rejections into an Error object', async () => { + const mutationFn = vi.fn().mockRejectedValue('string rejection'); + const { result } = renderHook(() => useMutation(mutationFn)); + + await act(async () => { + await result.current.mutate(undefined as any); + }); + + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.error?.message).toBe('string rejection'); + }); +}); diff --git a/src/hooks/useMutation.tsx b/src/hooks/useMutation.tsx new file mode 100644 index 00000000..95e2835b --- /dev/null +++ b/src/hooks/useMutation.tsx @@ -0,0 +1,147 @@ +'use client'; + +/** + * useMutation + * + * Lightweight hook for async write operations (POST / PUT / DELETE). + * Key properties: + * - Tracks isLoading / isSuccess / isError / data / error state. + * - Prevents double-submission via an in-flight ref guard; concurrent calls + * while a mutation is already running are silently dropped. + * - `mutate` – fire-and-forget; surfaces errors only via state. + * - `mutateAsync` – returns a Promise so callers can await/catch manually. + * - `reset` – returns state to idle without cancelling in-flight work. + */ + +import { useState, useCallback, useRef } from 'react'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface MutationState { + isLoading: boolean; + isSuccess: boolean; + isError: boolean; + data: TData | null; + error: Error | null; +} + +export interface MutationOptions { + /** Called after a successful mutation with the returned data and variables. */ + onSuccess?: (data: TData, variables: TVariables) => void | Promise; + /** Called when the mutation throws, before the error is stored in state. */ + onError?: (error: Error, variables: TVariables) => void | Promise; + /** Called after the mutation settles (success *or* error). */ + onSettled?: (data: TData | null, error: Error | null, variables: TVariables) => void; +} + +export interface MutationResult extends MutationState { + /** + * Trigger the mutation. Returns a void Promise that always resolves – errors + * are captured internally and surfaced via `isError` / `error` state. Use + * this when you do **not** need to inspect the result at the call-site. + */ + mutate: (variables: TVariables) => Promise; + /** + * Trigger the mutation and return the raw Promise. Rejects on failure so + * callers can `await` and `catch` themselves. + */ + mutateAsync: (variables: TVariables) => Promise; + /** Reset state back to idle. Does not cancel any in-flight async work. */ + reset: () => void; +} + +// ─── Initial state ──────────────────────────────────────────────────────────── + +const IDLE_STATE = { + isLoading: false, + isSuccess: false, + isError: false, + data: null, + error: null, +} as const; + +// ─── Hook ───────────────────────────────────────────────────────────────────── + +/** + * @template TData The type returned by the mutation function. + * @template TVariables The argument type accepted by the mutation function. + * Defaults to `void` for zero-argument mutations. + */ +export function useMutation( + mutationFn: (variables: TVariables) => Promise, + options: MutationOptions = {}, +): MutationResult { + const { onSuccess, onError, onSettled } = options; + + const [state, setState] = useState>(IDLE_STATE); + + /** Guards against concurrent calls: once true, new invocations are no-ops. */ + const inFlightRef = useRef(false); + + // Keep option callbacks in refs so they can be updated without re-creating + // `mutateAsync` (avoids stale-closure bugs without listing callbacks as deps). + const onSuccessRef = useRef(onSuccess); + const onErrorRef = useRef(onError); + const onSettledRef = useRef(onSettled); + onSuccessRef.current = onSuccess; + onErrorRef.current = onError; + onSettledRef.current = onSettled; + + const mutateAsync = useCallback( + async (variables: TVariables): Promise => { + // ── Double-submission guard ────────────────────────────────────────── + if (inFlightRef.current) { + // Already running – return a Promise that never resolves so the caller + // does not receive stale data. The existing in-flight call will update + // state when it completes. + return new Promise(() => {}); + } + + inFlightRef.current = true; + setState({ isLoading: true, isSuccess: false, isError: false, data: null, error: null }); + + try { + const data = await mutationFn(variables); + + setState({ isLoading: false, isSuccess: true, isError: false, data, error: null }); + + await onSuccessRef.current?.(data, variables); + onSettledRef.current?.(data, null, variables); + + return data; + } catch (raw) { + const error = raw instanceof Error ? raw : new Error(String(raw)); + + setState({ isLoading: false, isSuccess: false, isError: true, data: null, error }); + + await onErrorRef.current?.(error, variables); + onSettledRef.current?.(null, error, variables); + + throw error; + } finally { + inFlightRef.current = false; + } + }, + [mutationFn], + ); + + const mutate = useCallback( + async (variables: TVariables): Promise => { + try { + await mutateAsync(variables); + } catch { + // Errors are already captured in state; swallow here so fire-and-forget + // callers do not receive unhandled Promise rejection warnings. + } + }, + [mutateAsync], + ); + + const reset = useCallback(() => { + setState(IDLE_STATE); + }, []); + + return { ...state, mutate, mutateAsync, reset }; +} + +export default useMutation;