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
🖥️ Support Server Actions #11061
base: master
Are you sure you want to change the base?
🖥️ Support Server Actions #11061
Conversation
Review or Edit in CodeSandboxOpen the branch in Web Editor • VS Code • Insiders |
Size Change: +107 B (+1%) Total Size: 20.4 kB
|
4623505
to
24d2369
Compare
a3375be
to
0d12208
Compare
@bluebill1049 @jorisre @Moshyfawn |
Great!) The only thing is there is no example when validation occurs on the API server with an example response:
And also combining zodResolver and response api server |
@Myks92 I think it can be implemented as follows. What do you think? export async function action(prevState: any, formData: FormData) {
const validator = createActionValidator(formData, {
resolver: zodResolver(schema),
});
await validator.validate();
const res = await apiRequest();
// {
//. "errors": {
// "chatId": "The value should not be empty."
// }
// }
if (res?.errors) {
res.errors.forEach((message, key) => {
validator.setError(`root.${key}`, {
message: 'message',
});
});
}
if (!validator.isValid()) {
return validator.getResult();
}
return { message: 'Success!' };
} |
@kotarella1110 const resolvers = resolverChain([
zodResolver(schema),
apiResolver(request)
])
const validator = createValidator(formData, {
resolvers: resolvers
})
… Or apply this pattern to the Validator itself: const validator = validators([
zodValidator(schema),
apiValidator(request)
])
await validator.validate(formData) I would also transfer data validation from createAction Validator to the await validator.validate(formData) Total: Validator Contract interface Validator {
validate: (data: any) => Errors
} Error Contract interface ValidationError {
/**
* Returns the property path from the root element to the violation.
**/
path: string
/**
* Returns the violation message.
**/
message: string
/**
* Returns the value that caused the violation.
**/
invalidValue: string
}
interface Errors {
get: (path) => ValidationError
add: (ValidationError) => void
remove: (path) => void
errors: () => ValidationError[]
} Server Validator class ServerValidator implements Validator {
validate(data: any): Errors {
const errors new Errors()
const result = new api.post('/users/create', data)
if (result instanceof ValidationError) {
result.errors.forEach((error) => {
errors.add(new ValidationError(error.path, error.message, error?.invalidValue))
}
}
return errors;
}
} Zod Validator class ZodValidator implements Validator {
validate(data: any): Errors {
const errors new Errors()
//...
schema.parse(data)
//...
return errors;
}
} Chain Validator class ChainValidator implements Validator {
private validators
constructor(validators: Validator[]) {
this.validators = validators
}
validate(data: any): Errors {
const errors new Errors()
this.validators.forEach(validator => {
validator.validate().errors().forEach(error => {
errors.add(error)
})
})
return errors;
}
} Use: const validator = new ChainValidator([new ZodValidator(), new ServerValidator()])
const result = validator.validate({id: 1, name: 2})
console.log(result.errors()) Also, for the interface Validator {
validate: (data: any, rules?: Rule[]) => Errors
} Also, I don't think the validator needs to add global error handling functions from a server like this: validator.setError('root.serverError', {
type: '500',
message: 'Internal server error. Please try again later.',
}); The error does not apply to validation a data. It is important. It seems to me that it is necessary to separate validation from working with other errors. It may be worth separating action processing and validation since these are different responsibilities. Also, please do not judge strictly for my upper code. I was just trying to convey the idea of the flexibility of Validators, but not to make a full implementation. There is still something to think about. I don't like everything here. Maybe this will help in some way. In any case, your decision is long-awaited for me. I will be very glad to see this feature. |
Thank you for your feedback!
I thought it was a great idea to create a utility for composing multiple resolvers (it seems like there's a need for this on the client side as well).
It might be valuable to consider separating the responsibilities of action processing and validation as they indeed serve different purposes. |
After thinking about the validation, I thought that it might be related not only to the actions of the server. It can be used to make a request to the server. for example, we make a request to receive something. In total, the uuid was transmitted with an increment of the identifier. Thus, the api can only answer the question of flexible validation. It turns out you can do a more universal thing:
Then we can use different validators throughout the application. |
Yes, Option 2 has few changes, but it negatively impacts the UX.
I envision the usage as follows: action: export async function login(prevState: any, formData: FormData) {
const validator = createActionValidator(formData);
await validator
.register('username', { required: 'Username is required' })
.register('password', { {
minLength: {
value: 1,
message: 'Password must be at least 8 characters',
}
})
.validate();
if (!validator.isValid()) {
return validator.getResult();
}
// ...
} form: export function LoginForm() {
const [formState, action] = useFormState(login, {
values: {
username: '',
password: '',
}
});
const {
register,
handleSubmit,
formState: { errors },
} = useForm<{
username: string;
password: string;
}>({
defaultValues: formState.values,
errors: formState.errors,
progressive: true,
});
return (
<form action={action} onSubmit={handleSubmit()}>
<label>Username</label>
<input {...register('username', { required: 'Username is required' })} placeholder="Username" />
{errors.username && <p>{errors.username.message}</p>}
<label>Password</label>
<input {...register('password', {
minLength: {
value: 1,
message: 'Password must be at least 8 characters',
}
})} type="password" placeholder="Password" />
{errors.password && <p>{errors.password.message}</p>}
<SubmitButton />
{errors.root?.serverError && <p>{errors.root.serverError.message}</p>}
</form>
);
} In the context of built-in validation, it would be better if validation logic could be shared between the client and server, just like the schema. However, achieving this would likely require breaking changes.
If we choose Option 1, then 'errors' is a mandatory requirement. If we go with Option 2, either 'errors' or 'defaultErrors' becomes necessary. When the form submission triggers a page refresh, such as when JavaScript is disabled, you can render error messages by set react-dom's 'formState' as the default value for rhf's react-hook-form/src/useForm.ts Lines 69 to 72 in 7bd5060
In cases where the form submission doesn't result in a page refresh, you can render error messages by synchronizing react-dom's react-hook-form/src/useForm.ts Lines 123 to 127 in 7bd5060
Moreover, to seamlessly integrate react-dom's useEffect(() => {
setError("username", { message: formState.errors.username.message })
}, [setError, formState]); However, this approach doesn't work when JavaScript is disabled, as 'useEffect' doesn't run on SSR. Therefore, there is a need for |
We discussed which of the two proposals to adopt with @bluebill1049, and after careful consideration, we have decided to go with Proposal 1, "Execute preventDefault only on invalid in handleSubmit". This decision is based on the fact that Proposal 2 would result in a decrease in DX due to page refresh upon submission and the inability to support Also, we are considering improvements to the API in order to facilitate the sharing of built-in validations between the server and client. Here's how we plan to enhance the API:
import { createValidator } from "@hookform/validators"
const validator = createValidator({ // You may also consider naming it `validator`
rules: {
name: { require: true },
age: { min: 18, max: 99 }
}
})
// Server
validator.validate(formData)
// Client
useForm({
+ validator, // new
})
import { createZodValidator } from "@hookform/validators/zod"
const zodValidator = createZodValidator(option) // You may also consider naming it `zodValidator`
// Server
zodValidator.validate(formData)
// Client
useForm({
- resolver, // deprecate
+ validator: zodValidator, // new
}) combine: import { combineValidators } from "@hookform/validators"
import { createZodValidator } from "@hookform/validators/zod"
const validator = combineValidators(
createZodValidator(options),
createApiValidator(request)
);
validator.validate(formData) |
@kotarella1110, I already like this option. Looks good! |
Perhaps it is also worth giving developers the opportunity to transfer not only FormData, but also any other data to the validator. const data = {id:1, name: 'User'}
validator.validate(data)
//or
validator.validate(formData) This can be useful when we want to process data not only FormData. |
@Myks92 Yes, I intend to do so 👍 |
…e enhancement (#11078) * feat: execute preventDefault only on invalid * chore: update example * chore: update remix-form example * chore: update remix-form example * chore: update remix-form example * chore: update remix-form example * chore: update remix-form example * chore: update examples * chore: update hookform packages
366b662
to
87f4cb2
Compare
87f4cb2
to
47d2a0f
Compare
A bit late to the party but I'm the maintainer of remix-hook-form which handles PE & form actions in Remix, I've been doing this for a while and would be glad to help you guys on this! |
Thank you for your comment. I've thoroughly reviewed the code of remix-hook-form. |
@kotarella1110 if you put out a preview release with this change I can try it out in my Remix project |
Sorry in advance for the long comment @kotarella1110 Alright so I'll write a very detailed overview of everything I've figured out from the point I made it to the current release. I'd like to preface this that this is only valid for web standard API's and I have no idea what Next does, I know they like to add custom stuff on top of the web platform so I can't guarantee this will make much sense in that context. Progressive EnhancementSo for progressive enhancement use-cases here is a rough overview on what happens with form submissions without JS enabled:
Error handlingSo let's assume the Preserving old valuesSo here is the interesting part, if you submit something to the action and that gets validated and the errors are returned, this will work but there is a big problem when it comes to PE, because we did a native browser submission the values that were submitted are blown away and we have to re-enter everything we already entered. This is where we can do the following:
Once we return the initial data we can either provide it to the hook as <input {...register("input", { disableProgressiveEnhancement: true }) /> I think you need to provide a per value lever to be pulled here OR be smarter about it and figure out if something is controlled or not and handle it internally, in any case the point stands. Parsing the dataSo from the above scenario we notice the following two subscenarios when it comes to parsing the data:
I've made the following observation: // This generates an identical object as doing // request.formData
const searchParams = new URL(request.url).searchParams; So your options are the following:
SerializationAs you might have already known the
So both of these have their pros/cons and I will go into further detail on everything you need to be aware of for these approaches. Stringify everythingSo this approach is pretty simple, you stringify everything, even the strings. So this might look weird at first but here are the Pros:
Cons:
So when do we want to use this approach:
BenefitsReally simple and easy to use. Stringify everything besides stringsSo this approach differs from the above mentioned one in one key difference, we don't stringify strings. Pros:
Cons
BenefitsAligned with web standards Other concernsThe biggest concern in all of this is what happens with the following
I've made all of the above work in remix-hook-form but they are tied to Remix obviously, but all of the things I wrote are web standard related and things to figure out to bring true PE + server actions and validations. IMO what needs to happen on the server is the following:
PR review and my thoughtsI would maybe consider having the validator.validate() return the result instead of having to do isValid and getResult, make it a discriminated union so what users can do is: const { data, errors, defaultValues } = validator.validate();
if(errors){
// default values returned to frontend for PE
return json({ errors, defaultValues });
}
// Fully type-safe data here
data.value => string This would allow the most common use case to be the most simple, because most cases require you to only validate the data and not set custom fields (my experience from writing 100+ forms in remix). If you want something more advanced then you set the errors on the validator and you're good, although if you used the pattern of merging validators and had the validator actually do async checks, eg password validator that checks your db and validates then you don't even need the setError stuff. From the above writing you would also need to probably extend the useForm hook to handle the things I talked about. If you wish to discuss this @kotarella1110 feel free to message me on twitter or anywhere. |
One issue with falling back to GET requests is this would bypass CSRF protection. So personally in my apps I would want to disable this feature even at the cost of progressive enhancement |
@arjunyel |
Thank you very much for providing valuable information.
I have taken this into consideration in this PR as well, assuming that users would use the
Until I read the code of remix-hook-form, I completely overlooked the consideration for GET requests. Thank you for bringing this to my attention. I would like to support it similarly to remix-hook-form.
While I have knowledge about this, it was not considered at all in this PR. In the case of Remix, it provides hooks such as useSubmit to control the timing of submission. However, currently, React (Server Actions) does not provide such hooks. Therefore, if my understanding is correct, it is impossible to serialize FormData before requesting it to the server to support Progressive Enhancement. Hence, I believe we have no choice but to choose the "Stringify everything besides strings" approach. What we can do is provide an option for users to parse FormData at server-side validation.
As mentioned in the "Additional Note" of this PR overview, we need to discuss how to implement this in the future.
Thank you. Let's discuss it here for now to keep the conversation as open as possible! I will contact you individually if needed 😚 |
This could also work, but this means the user has to set the progressive enhancement up rather than the library doing it for him behind the scenes, it's very hard to say what the right approach here is because you either abstract it and make it simpler but then risking running into problems with controlled values or ask the user to set the value on every field, which could be a hassle. I can't really say if I prefer one or the other, from the users point of view I'd like to have everything set up for me but from the maintainer point of view I can see issues coming in that people get react errors when they register a component.
That is correct. Even though Remix provides this you are still not guaranteed that the user will serialize it himself. Nothing stopping me from just using Remix's My biggest gripe with that is the "false positives", you would have to force the user to coerce types into correct ones with their validation libraries. Not a big deal with new projects but a big deal with projects moving onto react-hook-form with server actions because suddenly their |
Thank you for your contributions! This Pull Request has been automatically marked as stale because it has not had any recent activity. It will be closed if no further activity occurs. Best, RHF Team ❤️ |
Issue
#10391
Summary
This PR is an experimental implementation for supporting Server Actions in React Hook Form. Its purpose is to explore the best way to support Server Actions within React Hook Form, and it's important to note that the changes proposed in this PR may not be adopted as is.
Additionally, this PR makes minimal changes, and there are various other considerations that need to be addressed.
Example
Server Actions
src/app/schema.ts
:src/app/action.ts
:src/app/form.tsx
:Remix Form
Two Proposals for Supporting Progressive Enhancement (or Progressive)
Currently, within useForm, there exists an
progressive
option designed to support Progressive. When this option is enabled, I am contemplating enabling one of the following two proposals. If you prefer not to change the behavior of the existingprogressive
option, we can also consider adding options such asserverActions
.=> As per #11061 (comment), we have decided to go with Proposal 1.
1. Execute preventDefault only on invalid in handleSubmit
PR
CodeSandbox
If Remix Form example doesn't work correctly on CodeSandbox, please try it locally.
Pros & Cons
Pros:
startTransition
useFormStatus
is workingCons:
preventDefault
synchronously within handleSubmit is required only on invalid submissions. If there is asynchronous processing before preventDefault, the browser will execute the submission during that time.preventDefault
synchronously entails more changes, including synchronous execution of native and schema validation processes.onSubmit
mode (field-level asynchronous validation, such asonChange
andonBlur
modes, are possible).2. Trigger submit on valid in handleSubmitPR
CodeSandbox
If Remix Form example doesn't work correctly on CodeSandbox, please try it locally.
Pros & Cons
Pros:
startTransition
preventDefault
synchronouslyCons:
useFormStatus
is not workingAPI
To support Server Actions, the following APIs have been added or changed. If you have better naming or design suggestions for these APIs, please provide your feedback.
New:
createActionValidator
This is a validation API for Server Actions or Remix Actions, facilitating consistent validation on both the frontend and backend. The
getResult
function returns the validation results, including the submitted form values and validation errors. The structure of thevalues
anderrors
objects aligns with the structure ofvalues
anderrors
handled by React Hook Form, further simplifying integration withuseForm
.Change:
progressive
propEnabling this prop will activate the features mentioned in the proposals. This is a breaking change. If you want to avoid breaking changes, you can consider adding a new prop like
serverActions
or a new hook likeuseActionForm
.New:
errors
prop => DONE: #11188This prop will react to changes and update the errors, which is useful when your form needs to be updated by external state or server data. You can pass the errors returned from a Server Action to this prop to render error messages regardless of whether JavaScript is enabled or disabled.
The
error
prop could be beneficial even for users who do not use Server Actions. Therefore, it may be worthwhile to address it separately from Server Actions support and release it in advance.import { experimental_useFormState as useFormState, experimental_useFormStatus as useFormStatus, } from 'react-dom'; import { useForm } from 'react-hook-form'; const [formState, action] = useFormState(login, null); const { register, handleSubmit, formState: { errors }, } = useForm({ + errors: formState.errors, progressive: true, });
New:defaultErrors
props=> This prop is not required for Server Actions support. if this prop is needed, it can be included later as well.
This prop allows you to specify default errors. With the addition of the
errors
prop, thedefaultErrors
prop has also been added. Note that adding this prop is not mandatory for Server Actions support.The
defaultErrors
prop, just likeerrors
prop, it may be worthwhile to address it separately from Server Actions support and release it in advance.import { experimental_useFormState as useFormState, experimental_useFormStatus as useFormStatus, } from 'react-dom'; import { useForm } from 'react-hook-form'; const [formState, action] = useFormState(login, null); const { register, handleSubmit, formState: { errors }, } = useForm({ + defaultErrors: formState.errors, progressive: true, });
Change:
handleSubmit
In anticipation of an increased use case for not specifying arguments for handleSubmit due to the support of Server Actions, we have made the
onValid
argument optional.Credit
This PR has been made possible with the invaluable advice and support of @koichik. With heartfelt gratitude 💚
Additional Note
This PR makes minimal changes. Therefore, the following tasks will be addressed in the future:
Form
component<Form serverAction="" />
Optimization of build configurations using esbuild or tsupformState
properties such asisSubmitting
in React Hook FormuseFormStatus
anduseNavigation
into theformState
. This is because I haven't come up with a suitable method for integration. If you have any suggestions for a good way to integrate them, please let me know.handleSubmit
,resolver
, andregister
according to theprogressive
prop.