Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/formstate-submit-meta.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"el-form-react-hooks": minor
---

Add submit-status fields to `formState` for React Hook Form parity: `isSubmitted` (true after the first submit attempt), `submitCount` (number of submit attempts), and `isSubmitSuccessful` (true when the last submit passed validation and the submit handler ran without throwing). All three are reset by `reset()`. Set consistently across `handleSubmit`, `submit()`, and `submitAsync()`. Purely additive.

(`isValidating` and a reactive `dirtyFields` field are planned as a fast-follow.)
3 changes: 2 additions & 1 deletion docs/docs/guides/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ const { register, handleSubmit, formState } = useForm({
`onBlur`, `ref`) and supports nested paths like `register("address.city")` and array
paths like `register("items.0.name")`.
- `handleSubmit(onValid, onError?)` validates, then calls your handler.
- `formState` exposes `errors`, `isDirty`, `isValid`, `isSubmitting`, `touched`.
- `formState` exposes `errors`, `isDirty`, `isValid`, `isSubmitting`, `touched`, plus the
submit-status fields `isSubmitted`, `isSubmitSuccessful`, and `submitCount`.
- `setValue`, `reset`, `watch`, `trigger`, `setFocus`, `getFieldState` all exist.
- `useFieldArray` exists for dynamic lists (see [Array Fields](./array-fields.md)).
- Focus-on-error is on by default (`shouldFocusError`), just like RHF.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, act, cleanup, fireEvent, waitFor } from "@testing-library/react";
import React from "react";
import { z } from "zod";
import { useForm } from "..";

beforeEach(cleanup);

function makeApp(onSubmit?: (d: any) => void) {
let api: any;
function App() {
const form = useForm<{ name: string }>({
defaultValues: { name: "" },
validators: { onSubmit: z.object({ name: z.string().min(1, "required") }) },
onSubmit,
});
api = form;
return (
<>
<div data-testid="submitted">{String(form.formState.isSubmitted)}</div>
<div data-testid="count">{form.formState.submitCount}</div>
<div data-testid="success">{String(form.formState.isSubmitSuccessful)}</div>
</>
);
}
return { App, getApi: () => api };
}

const submitted = () => screen.getByTestId("submitted").textContent;
const count = () => screen.getByTestId("count").textContent;
const success = () => screen.getByTestId("success").textContent;

describe("formState submit metadata", () => {
it("starts at isSubmitted=false, submitCount=0, isSubmitSuccessful=false", () => {
const { App } = makeApp();
render(<App />);
expect(submitted()).toBe("false");
expect(count()).toBe("0");
expect(success()).toBe("false");
});

it("a valid submit sets isSubmitted, submitCount, and isSubmitSuccessful", async () => {
const onSubmit = vi.fn();
const { App, getApi } = makeApp(onSubmit);
render(<App />);
await act(async () => {
getApi().setValue("name", "ada");
});
await act(async () => {
await getApi().submit();
});
expect(submitted()).toBe("true");
expect(count()).toBe("1");
expect(success()).toBe("true");
expect(onSubmit).toHaveBeenCalledTimes(1);
});

it("an invalid submit marks submitted + counts, but isSubmitSuccessful stays false", async () => {
const onSubmit = vi.fn();
const { App, getApi } = makeApp(onSubmit);
render(<App />); // name "" -> invalid
await act(async () => {
await getApi().submit();
});
expect(submitted()).toBe("true");
expect(count()).toBe("1");
expect(success()).toBe("false");
expect(onSubmit).not.toHaveBeenCalled();
});

it("submitCount increments per attempt", async () => {
const { App, getApi } = makeApp(() => {});
render(<App />);
await act(async () => {
await getApi().submit();
});
await act(async () => {
await getApi().submit();
});
expect(count()).toBe("2");
});

it("handleSubmit also sets the submit metadata", async () => {
const onValid = vi.fn();
function App() {
const form = useForm<{ name: string }>({
defaultValues: { name: "ada" }, // valid
validators: { onSubmit: z.object({ name: z.string().min(1, "required") }) },
});
return (
<form data-testid="form" onSubmit={form.handleSubmit(onValid)}>
<div data-testid="count">{form.formState.submitCount}</div>
<div data-testid="success">{String(form.formState.isSubmitSuccessful)}</div>
</form>
);
}
render(<App />);
fireEvent.submit(screen.getByTestId("form"));
await waitFor(() => expect(count()).toBe("1"));
expect(success()).toBe("true");
expect(onValid).toHaveBeenCalledTimes(1);
});

it("a submit whose handler throws leaves isSubmitSuccessful false (count still increments)", async () => {
const onSubmit = vi.fn(() => {
throw new Error("boom");
});
const { App, getApi } = makeApp(onSubmit);
render(<App />);
await act(async () => {
getApi().setValue("name", "x"); // valid -> handler runs
});
await act(async () => {
await expect(getApi().submit()).rejects.toThrow("boom");
});
expect(submitted()).toBe("true");
expect(count()).toBe("1");
expect(success()).toBe("false");
});

it("reset() clears submit metadata", async () => {
const { App, getApi } = makeApp(() => {});
render(<App />);
await act(async () => {
getApi().setValue("name", "x");
});
await act(async () => {
await getApi().submit();
});
expect(count()).toBe("1");
await act(async () => {
getApi().reset();
});
expect(submitted()).toBe("false");
expect(count()).toBe("0");
expect(success()).toBe("false");
});
});
7 changes: 7 additions & 0 deletions packages/el-form-react-hooks/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ export interface FormState<T extends Record<string, any>> {
isSubmitting: boolean;
isValid: boolean;
isDirty: boolean;
/** True after the first submit attempt. Reset by `reset()`. */
isSubmitted: boolean;
/** True when the last submit passed validation and the submit handler ran
* without throwing. Reset by `reset()`. */
isSubmitSuccessful: boolean;
/** Number of submit attempts. Reset by `reset()`. */
submitCount: number;
}

export interface FormSnapshot<T extends Record<string, any>> {
Expand Down
6 changes: 6 additions & 0 deletions packages/el-form-react-hooks/src/useForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ export function useForm<T extends Record<string, any>>(
isSubmitting: false,
isValid: false,
isDirty: false,
isSubmitted: false,
isSubmitSuccessful: false,
submitCount: 0,
});

// Separate state for file previews
Expand Down Expand Up @@ -516,6 +519,9 @@ export function useForm<T extends Record<string, any>>(
isSubmitting: false,
isValid: false,
isDirty: options?.keepDirty ? formState.isDirty : false,
isSubmitted: false,
isSubmitSuccessful: false,
submitCount: 0,
});

// Clear file previews on reset
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ function makeState(
isSubmitting: false,
isValid: true,
isDirty: false,
isSubmitted: false,
isSubmitSuccessful: false,
submitCount: 0,
...overrides,
};
}
Expand Down
16 changes: 14 additions & 2 deletions packages/el-form-react-hooks/src/utils/__tests__/formState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,18 @@ function createStateHarness<T extends Record<string, any>>(
}

const createManager = (
initialState: FormState<TestValues>,
defaultValues: Partial<TestValues> = initialState.values
initial: Omit<
FormState<TestValues>,
"isSubmitted" | "isSubmitSuccessful" | "submitCount"
>,
defaultValues: Partial<TestValues> = initial.values
) => {
const initialState: FormState<TestValues> = {
isSubmitted: false,
isSubmitSuccessful: false,
submitCount: 0,
...initial,
};
const harness = createStateHarness(initialState);
const dirtyManager = createDirtyStateManager<TestValues>({
current: new Set<string>(),
Expand Down Expand Up @@ -126,6 +135,9 @@ describe("createFormStateManager", () => {
isSubmitting: false,
isValid: false,
isDirty: false,
isSubmitted: false,
isSubmitSuccessful: false,
submitCount: 0,
});
expect(dirtyManager.dirtyFieldsRef.current.size).toBe(0);
});
Expand Down
3 changes: 3 additions & 0 deletions packages/el-form-react-hooks/src/utils/formHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ export function createFormHistoryManager<T extends Record<string, any>>(
isSubmitting: false,
isValid: Object.keys(errors).length === 0,
isDirty: dirtyManager.dirtyFieldsRef.current.size > 0,
isSubmitted: false,
isSubmitSuccessful: false,
submitCount: 0,
});
},

Expand Down
3 changes: 3 additions & 0 deletions packages/el-form-react-hooks/src/utils/formState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ export function createFormStateManager<T extends Record<string, any>>(
isSubmitting: false,
isValid: false,
isDirty: false,
isSubmitted: false,
isSubmitSuccessful: false,
submitCount: 0,
});
},

Expand Down
27 changes: 24 additions & 3 deletions packages/el-form-react-hooks/src/utils/submitOperations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,13 @@ export function createSubmitOperationsManager<T extends Record<string, any>>(
) => {
return async (e: React.FormEvent) => {
e.preventDefault();
setFormState((prev) => ({ ...prev, isSubmitting: true }));
setFormState((prev) => ({
...prev,
isSubmitting: true,
isSubmitted: true,
submitCount: prev.submitCount + 1,
isSubmitSuccessful: false,
}));

const { isValid, errors } = await validationManager.validateForm(
formState.values
Expand All @@ -75,6 +81,7 @@ export function createSubmitOperationsManager<T extends Record<string, any>>(

if (isValid) {
await onValid(formState.values as T);
setFormState((prev) => ({ ...prev, isSubmitSuccessful: true }));
} else {
if (shouldFocusError !== false) {
const el = findFirstErrorElement(
Expand All @@ -97,7 +104,13 @@ export function createSubmitOperationsManager<T extends Record<string, any>>(
return;
}

setFormState((prev) => ({ ...prev, isSubmitting: true }));
setFormState((prev) => ({
...prev,
isSubmitting: true,
isSubmitted: true,
submitCount: prev.submitCount + 1,
isSubmitSuccessful: false,
}));

try {
// Always validate before submitting
Expand All @@ -113,6 +126,7 @@ export function createSubmitOperationsManager<T extends Record<string, any>>(

if (isValid) {
await onSubmit(formState.values as T);
setFormState((prev) => ({ ...prev, isSubmitSuccessful: true }));
}
} finally {
setFormState((prev) => ({ ...prev, isSubmitting: false }));
Expand All @@ -123,7 +137,13 @@ export function createSubmitOperationsManager<T extends Record<string, any>>(
| { success: true; data: T }
| { success: false; errors: Partial<Record<keyof T, string>> }
> => {
setFormState((prev) => ({ ...prev, isSubmitting: true }));
setFormState((prev) => ({
...prev,
isSubmitting: true,
isSubmitted: true,
submitCount: prev.submitCount + 1,
isSubmitSuccessful: false,
}));

try {
// Always validate before submitting
Expand All @@ -142,6 +162,7 @@ export function createSubmitOperationsManager<T extends Record<string, any>>(
if (onSubmit) {
await onSubmit(formState.values as T);
}
setFormState((prev) => ({ ...prev, isSubmitSuccessful: true }));
return { success: true, data: formState.values as T };
} else {
return { success: false, errors };
Expand Down