Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix useActionData re-render issue, refactored mergeErrors & standardize errors to react-hook-form #35

Closed
wants to merge 4 commits into from
Closed
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
26 changes: 18 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
![GitHub Repo stars](https://img.shields.io/github/stars/Code-Forge-Net/remix-hook-form?style=social)
![npm](https://img.shields.io/npm/v/remix-hook-form?style=plastic)
![GitHub](https://img.shields.io/github/license/Code-Forge-Net/remix-hook-form?style=plastic)
![npm](https://img.shields.io/npm/dy/remix-hook-form?style=plastic)
![GitHub top language](https://img.shields.io/github/languages/top/Code-Forge-Net/remix-hook-form?style=plastic)
![npm](https://img.shields.io/npm/dy/remix-hook-form?style=plastic)
![GitHub top language](https://img.shields.io/github/languages/top/Code-Forge-Net/remix-hook-form?style=plastic)

Remix-hook-form is a powerful and lightweight wrapper around [react-hook-form](https://react-hook-form.com/) that streamlines the process of working with forms and form data in your [Remix](https://remix.run) applications. With a comprehensive set of hooks and utilities, you'll be able to easily leverage the flexibility of react-hook-form without the headache of boilerplate code.

And the best part? Remix-hook-form has zero dependencies, making it easy to integrate into your existing projects and workflows. Say goodbye to bloated dependencies and hello to a cleaner, more efficient development process with Remix-hook-form.
And the best part? Remix-hook-form has zero dependencies, making it easy to integrate into your existing projects and workflows. Say goodbye to bloated dependencies and hello to a cleaner, more efficient development process with Remix-hook-form.

Oh, and did we mention that this is fully Progressively enhanced? That's right, you can use this with or without javascript!

Expand Down Expand Up @@ -99,7 +99,7 @@ If the form is submitted without js it will try to parse the formData object and
getValidatedFormData is a utility function that can be used to validate form data in your action. It takes two arguments: the request object and the resolver function. It returns an object with three properties: `errors`, `receivedValues` and `data`. If there are no errors, `errors` will be `undefined`. If there are errors, `errors` will be an object with the same shape as the `errors` object returned by `useRemixForm`. If there are no errors, `data` will be an object with the same shape as the `data` object returned by `useRemixForm`.

The `receivedValues` property allows you to set the default values of your form to the values that were received from the request object. This is useful if you want to display the form again with the values that were submitted by the user when there is no JS present

#### Example with errors only
If you don't want the form to persist submitted values in the case of validation errors then you can just return the `errors` object directly from the action.
```jsx
Expand Down Expand Up @@ -212,6 +212,16 @@ If you're using a GET request formData is not available on the request so you ca

`useRemixForm` is a hook that can be used to create a form in your Remix application. It's basically the same as react-hook-form's [`useForm`](https://www.react-hook-form.com/api/useform/) hook, with the following differences:

### usePrevious

`usePrevious` is a common custom hook that takes in a data object and return the previous state of that data. This is a simple version of it that is pretty efficient. It will return undefined on the first render as it never had a previous value.

### useIsNewData

`useIsNewData` is a custom hook that takes in a data object and as long as the data in different it will return the new value. Its used in this package to prevent remix's default behavior of useActionData returning the same value on every re-render. Thus, needing to alway submit your form on every state change.

If you would rather have remix default behavior just set `shouldResetActionData = true` on `useRemixForm` function call.

**Additional options**
- `submitHandlers`: an object containing two properties:
- `onValid`: can be passed into the function to override the default behavior of the `handleSubmit` success case provided by the hook.
Expand Down Expand Up @@ -242,7 +252,7 @@ The `errors` object inside `formState` is automatically populated with the error
const { ... } = useRemixForm({
...ALL_THE_SAME_CONFIG_AS_REACT_HOOK_FORM,
submitHandlers: {
onValid: data => {
onValid: data => {
// Do something with the formData
},
onInvalid: errors => {
Expand Down Expand Up @@ -285,7 +295,7 @@ Identical to the [`FormProvider`](https://react-hook-form.com/api/formprovider/)
```jsx
export default function Form() {
const methods = useRemixForm();

return (
<RemixFormProvider {...methods} > // pass all methods into the context
<form onSubmit={methods.handleSubmit}>
Expand All @@ -304,7 +314,7 @@ Exactly the same as [`useFormContext`](https://react-hook-form.com/api/useformco
```jsx
export default function Form() {
const methods = useRemixForm();


return (
<RemixFormProvider {...methods} > // pass all methods into the context
Expand All @@ -324,7 +334,7 @@ const NestedInput = () => {
```


## Support
## Support

If you like the project, please consider supporting us by giving a ⭐️ on Github.
## License
Expand Down
39 changes: 38 additions & 1 deletion src/hook/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ describe("useRemixForm", () => {
isSubmitSuccessful: false,
isSubmitted: false,
isSubmitting: false,
isValid: false,
isValid: true,
isValidating: false,
touchedFields: {},
submitCount: 0,
Expand Down Expand Up @@ -68,6 +68,43 @@ describe("useRemixForm", () => {
});
});

it("should call onInvalid function when the form is invalid and isValid should be false", async () => {
const onValid = vi.fn();
const onInvalid = vi.fn();
const errors = { name: { message: "Name is required" } };
const values = { name: "" };

const { result } = renderHook(() =>
useRemixForm({
resolver: () => ({ values, errors }),
submitHandlers: {
onValid,
onInvalid,
},
}),
);

act(() => {
result.current.handleSubmit();
});

await waitFor(() => {
expect(result.current.formState).toEqual({
dirtyFields: {},
isDirty: false,
isSubmitSuccessful: false,
isSubmitted: false,
isSubmitting: false,
isValid: false,
isValidating: false,
touchedFields: {},
submitCount: 1,
isLoading: false,
errors,
});
});
});

it("should submit the form data to the server when the form is valid", async () => {
const { result } = renderHook(() =>
useRemixForm({
Expand Down
81 changes: 69 additions & 12 deletions src/hook/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import type {
UseFormProps,
UseFormReturn,
} from "react-hook-form";
import { createFormData, mergeErrors } from "../utilities";
import { createFormData, mergeErrors, safeKeys } from "../utilities";

export type SubmitFunctionOptions = Parameters<SubmitFunction>[1];

Expand All @@ -33,21 +33,25 @@ export interface UseRemixFormOptions<T extends FieldValues>
submitConfig?: SubmitFunctionOptions;
submitData?: FieldValues;
fetcher?: FetcherWithComponents<T>;
shouldResetActionData?: boolean;
}

export const useRemixForm = <T extends FieldValues>({
submitHandlers,
submitConfig,
submitData,
fetcher,
shouldResetActionData = false,
...formProps
}: UseRemixFormOptions<T>) => {
const actionSubmit = useSubmit();
const actionData = useActionData();
const submit = fetcher?.submit ?? actionSubmit;
const data: any = fetcher?.data ?? actionData;
const data = fetcher?.data ?? actionData;
const methods = useForm<T>(formProps);
const navigation = useNavigation();
const shouldMergeErrors = useIsNewData(data, shouldResetActionData);

// Either it's submitted to an action or submitted to a fetcher (or neither)
const isSubmittingForm =
navigation.state !== "idle" || (fetcher && fetcher.state !== "idle");
Expand All @@ -60,7 +64,7 @@ export const useRemixForm = <T extends FieldValues>({
});
};
const values = methods.getValues();
const validKeys = Object.keys(values);
const validKeys = safeKeys(values);
// eslint-disable-next-line @typescript-eslint/no-empty-function
const onInvalid = () => {};

Expand All @@ -70,21 +74,23 @@ export const useRemixForm = <T extends FieldValues>({
dirtyFields,
isDirty,
isSubmitSuccessful,
isSubmitted,
isSubmitting,
isValid,
isValidating,
touchedFields,
submitCount,
errors,
errors: frontendErrors,
isLoading,
} = formState;

const formErrors = mergeErrors<T>(
errors,
data?.errors ? data.errors : data,
validKeys,
);
// remix-hook-forms should only process data errors that conform to react-hook-form error data types.
const errors =
shouldMergeErrors && data?.errors
? mergeErrors(frontendErrors, data?.errors, validKeys)
: frontendErrors;

const isValid = !(Object.keys(errors).length > 0);
const isSubmitted =
data && Object.keys(data).length > 0 && isValid ? true : false;

return {
...methods,
Expand Down Expand Up @@ -114,7 +120,7 @@ export const useRemixForm = <T extends FieldValues>({
touchedFields,
submitCount,
isLoading,
errors: formErrors,
errors,
},
};
};
Expand All @@ -140,3 +146,54 @@ export const useRemixFormContext = <T extends FieldValues>() => {
>,
};
};

/**
* usePrevious takes in a data object and return the previous state of that data
*
* @export
* @template T
* @param {T} data
* @returns {*}
*/
export function usePrevious<T extends { [key: string]: any }>(data: T) {
const ref = React.useRef<T>();

React.useEffect(() => {
ref.current = data;
}, [data]);

return ref.current;
}

/**
* useIsNewData will only return data if it is new data.
* This useIsNewData hook is used to maintain react-hook-form default behavior and
* prevents remix default behavior of returning useActionData on every rerender.
* This can be overridden by passing shouldResetActionData: true to useRemixForm,
* and it will restore remix default behavior.
*
* @export
* @template DataType
* @param {DataType} data
* @param {boolean} [shouldResetActionData=false] if set to true will always return data.
* @returns {(DataType | undefined)}
*/
export function useIsNewData<
DataType extends { [key: string]: any } | undefined,
>(data: DataType, shouldResetActionData = false): DataType | undefined {
const oldData = usePrevious(data || {});

if (!data || Object.keys(data).length < 1) {
return undefined;
}

if (shouldResetActionData) {
return data;
}

if (data === oldData) {
return undefined;
}

return data;
}
Loading