IdoPesok / zsa

https://zsa.vercel.app
MIT License
447 stars 13 forks source link

Best way of accessing invalid values with progressive enhancement in mind #102

Closed gbouteiller closed 4 weeks ago

gbouteiller commented 1 month ago

In the context of using input with type="state" and invalid values with progressive enhancement in mind, is there a way of getting these (invalid) values to pass it for instance as defaultValues in RHF (in that way, the form can be filled with the values during server rendering even if javascript is disabled)?

actions.ts

"use server"

export const subscribeToNewsletterAction = createServerAction()
  .input(zNewsletterValues, {type: "state"})
  .handler(async ({input: {email}}) => subscribeToNewsletter(email))

newsletter-form.tsx

"use client"

export default function NewsletterForm() {
  const [[data, error], action, pending] = useActionState(subscribeToNewsletterAction, [null, null])

 const form = useForm<NewsletterValues>({
    mode: "onTouched",
    resolver: zodResolver(zNewsletterValues),
    defaultValues: ... // error?.values or similar
  })

For now, my workaround is to create an extra function like the one in custom state when I can access formData directly and pass it through:

actions.ts

"use server"

const subscribeToNewsletterZSA = createServerAction()
  .input(zNewsletterValues, {type: "state"})
  .handler(async ({input: {email}}) => subscribeToNewsletter(email))

export const subscribeToNewsletterAction = async (
  _prev: NewsletterState,
  formData: FormData
): Promise<NewsletterState> => {
  const [data, error] = await subscribeToNewsletterZSA(formData)
  if (data) return [data, null] 
  const values = Object.fromEntries(formData.entries()) as NewsletterValues
  return [null, {...error, values}]
}
IdoPesok commented 1 month ago

Hi, thank you for this suggestion. I am on board with this. This will most likely be merged in with shapeError.

Here is a quick update of what it will look like:

Screen Shot 2024-06-07 at 4 41 08 PM

This was incredibly tricky to get the types to align, but there is progress! The problem here is that most people will likely put their shape errors function in procedures. In doing that, there is a challenge to actually type the errors correctly with the correct zod schema. Why? Because the zod schemas will most likely get defined in the actions -- which the procedure doesn't have types for.

It was incredibly important for me that even if you define shape error in a procedure, your errors will still be typed based on the final schema defined in your action. Happy to report, it is (somewhat) working! As you can see in the image, the final error type is a combination of the procedure input and action input -- even though the procedure never has access to the action types. Users will need to use a special typedData object when passing forward these types.

Looking for feedback before it gets merged!

gbouteiller commented 1 month ago

Yes, I understand what you mean, types at that level can be really tricky 😄 but the result looks really promising. Awesome job!

IdoPesok commented 4 weeks ago

Give it a shot with zsa@0.3.4! Documentation. Note that shapeError is experimental for now on the basis that I am not 100% sure if API with typedData is the most intuitive -- looking for feedback.

gbouteiller commented 4 weeks ago

Hello @IdoPesok , I just wanted to show you the code, it works perfectly! Thank you so much.

actions.ts

"use server"

export const formAction = createServerActionProcedure()
  .experimental_shapeError(({err, typedData}) => {
    const values = Object.fromEntries(Object.entries(typedData.inputRaw).filter(([name]) => !name.startsWith("$ACTION")))
    if (!(err instanceof ZSAError)) return {code: "INTERNAL_SERVER_ERROR" as const, errors: {root: {type: "INTERNAL_SERVER_ERROR"}}, values}
    const {code, inputParseErrors, message} = err
    const errors = {
      root: {type: code, message: message ?? inputParseErrors?.formErrors?.[0]},
      ...Object.fromEntries(Object.entries(inputParseErrors?.fieldErrors ?? {}).map(([name, errors]) => [name, {message: errors?.[0]}])),
    }
    return {code, errors, values}
  })
  .handler(() => {})
  .createServerAction()

export const subscribeToNewsletterAction = formAction
  .experimental_shapeError(({ctx: {code, errors, values}}) => (code === "CONFLICT" ? {code, errors} : {code, errors, values}))
  .input(zNewsletterValues, {type: "state"})
  .output(z.boolean())
  .handler(async ({input: {email}}) => {
    const code = await subscribeToNewsletter(email)
    if (code !== "SUCCESS") throw new ZSAError(code)
    return true
  })

newsletter-form.tsx

"use client"

export default function NewsletterForm() {
  const [[success, failure], action, pending] = useActionState(subscribeToNewsletterAction, [null, null])

  const form = useForm<NewsletterValues>({
    mode: "onTouched",
    resolver: zodResolver(zNewsletterValues),
    defaultValues: failure?.values ?? defaultNewsletterValues,
    errors: failure?.errors,
  })
IdoPesok commented 4 weeks ago

Awesome -- happy to hear!! Code looks great.