Ok, I created a minimum sample of what I
Ok, I created a minimum sample of what I'm trying to achieve with Convex, NextJS, React Hook Form, and Zod and hitting a wall because Convex wants undefined, and React forms wants "" as a representation of blanks. I'll drop the code in a thread to minimize noise to channel. I'd appreciate any help.
36 Replies
Here's the code: https://github.com/pupeno/convex-nextjs-playground
The type error is here:
fee can't be "" but it also can't be undefined: https://github.com/pupeno/convex-nextjs-playground/blob/a7d4be878c2557f526dc3fd4c5593ffb82300621/app/admin/competitions/_lib/form.tsx#L23
@Sara @Ashes if you are still around, and free, I'd appreciate any help here.
The validation: https://github.com/pupeno/convex-nextjs-playground/blob/main/lib/validation/competitions.ts
It already is, but the schema is not the problem: https://github.com/pupeno/convex-nextjs-playground/blob/a7d4be878c2557f526dc3fd4c5593ffb82300621/convex/schema.ts#L7
Oh, I forgot to do the server side Zod validation.The simplest fix to me would be doing
And yeah it's annoying I'm remembering now you need the blank strings to have it always be a controlled component. Doing the spread operation helps to only sanitize the fields you want before submitting while allowing new fields to be added and not having to update that submit object
@Ashes yes, I can do that, but that doesn't solve this problem:
fee: parseInt(fee) || undefined
There are a couple cleaner solves than parseInt but it's a bit longer to type out
fee there can't be undefined:
You should avoid providing undefined as a default value, as it conflicts with the default state of a controlled component.https://react-hook-form.com/docs/useform#defaultValues This is where I'm hitting the problem. BTW, it can manifest in other wacky ways (with inputs switching between controlled and uncontrolled depending on whether the field has a value in the server). This is just the clearest way to visualize how the incompatibility between RHF using
"" and Convex using undefined breaks the ability to use the same type accross, and I'm not sure where to draw the line.
I tried converting the value on onChange, but that doesn't work either. When you set it to undefined, RHF or React, grabs the value from values, making it impossible to clear the value in the form (that's how I discovered the bug).Yup yup, the default blank string I think can remain there. It's in the onSubmit function where this would be adjusted line 28:
Ok, but for the blank string to remain there, I need to change types somewhere.
This should let the input remain controlled, then when the person hits submit the form parses that field. If it turns out to be a real number then it submits that to the backend, if it's not a number it submits undefined to the backend and that will behave for convex
Oh is it giving you an issue that the string is changing to a number in the number field?
Ooooh for zod?
No, the problem is that useForm is using the Competition type, and in that, fee is a number | undefined, not a string.
I'm too new to Convex, Zod, etc to have a good intuition of where I draw the line between client side types and server side types. I'm trying not to duplicate validation.
If I have to duplicate things on the server and the client, I might as well use Rails 😉
Yeah Im a bit rusty with zod myself. This is a normal annoyance though. Lemme check something
I think this is what you want to change, using coerce before .number in your validator?
With
z.coerce.number(), the output of export type Competition = z.infer<typeof CompetitionValidator>; still has fee as a number, which is not unreasonable, but that means:
I would set the default value to 0 and that would work with the spread operation logic before
Give me a sec and let me show you something.
Or the real stupid but easy fix
fee: "" as number
Which is something I personally do when I want a text field to be an ID of a convex model, e.g.:
userId: "" as Id<"users">
This lets me have a blank but controlled field that accepts only the IDs for users
I think I'll hit another problem, hold on a sec.
This is what I need to do to convert between string and number, in both directions, but I think I know put myself in a position where the Zod validation doesn't work on the client, I'm going to try that: https://github.com/pupeno/convex-nextjs-playground/pull/1/files
It's a bit late for me but I'll be around tomorrow. I do twitch streams for the project I'm working on so if you're still running into stuff Id be able to look at it/respond much clearer
Where do I find your streams?
Yup... now with that change I can't use a zod resolver to validate on the client sigh.
I'll take a look
I see your problem, so I'd suggest keeping everything as is,
when you submit the form use
parseFloat(fee,10) to change it to a number,
and define,
something like this
and you should be good
good old stack overflow to the rescue,
https://stackoverflow.com/questions/73582246/zod-schema-how-to-make-a-field-optional-or-have-a-minimum-string-contraint
what this does is it goes, hey, are you empty? then you must be undefined, else you're a number, but if you're fully empty you'll just be undefinedWhen doing this, the type error moves to the mutation function:
Now that fee is allowed to be of type string, I can't pass it to insert, because the type in the schema is
fee: v.optional(v.number()). This just moves the type problem from the client to the server.type cast it at the end
if it's undefined don't call the function
so:
or to be safe, parseFloat
Yeah, parseFloat or something else is a detail. But I'm not sure where that snippet of code you just pasted is supposed to go.
The problem is that
z.union([z.string().length(0), z.number().positive().finite()]) causes const competitionArgs = zodToConvex(CompetitionValidator); to generate a type that allows string, so in the mutation:
now it allows strings in, which may or may not be ok, but even CompetitionValidator.parse() outputs a type with string for fee.wait where are you using zodToConvex?
in a table?
oh!
well this changes things
can you pass 0 to the form?
But even if I remove it, and I write my args type by hand,
CompetitionValidator.parse() would still return the wrong type, in here:
https://github.com/pupeno/convex-nextjs-playground/blob/6178e4d5b1e27dcd603d8c6aa56b14b09ae14f21/convex/admin/competitions.ts#L54rollback to this version for a min
can you pass zero to the form?
Not really, and here let me clarify something. Yes, I can find a hack that would work here, but I'm building this project to learn the Convex/NextJS way (I'm switching from Rails/Stimulus being my go-to tool), to then go apply it on bigger apps than I'm planning on building if it looks like it can do the job and I can be productive enough in it. So I'm pushing hard in all my decisions to be generic because my goal is to build my toolbox, not having this app working (if it was the later, I would have done it in a weekend or two in Rails really).
I can also go and have one Zod validation for the server, one for the client, and hard-code the shape/types on each stage. That would work, but it would have a lot of boilerplate code.
Yup.
here's another solution, I wouldn't say its hacky
https://stackoverflow.com/a/76878814
Stack Overflow
React hook form and zod inumber input
I have created a form with react hook form and did a validation with zod. In one input I want to write in numbers, when I write any number, the error says it is not a number:
<Controller
and you can define the length to be 0
z.preprocess(
(a) => parseInt(z.string().parse(a), 10),
z.number().positive().min(1)
)
That one?yep yep
I think that would fix it
Unfortunately, no. The problem is in the types. It's impossible to have a type that accepts strings and doesn't accept strings at the same time, depending on where you use it.
That Zod makes the
fee input unknown, making it incompatible in a bunch of places where the types are used.
Some of the type errors I actually don't completely understand yet.
Even if I get this to work, I still need to convert it to null to send it to the mutation, and then convert it to undefined to send it to patch.sorry we've might've overcomplicated this when it was just too simple, I can see your input is type number, default it to zero like I advised before, and in your backend function do the check to see if it equals to zero and return undefined,
if it tells you that the number returns type string, just do
.transform((d)=> parseFloat(d || "0",10)) in your zod schemaThank you.
Wooohoo glad ya'll solved that, one more "gotcha" to throw in the back of the brain 🙂
I haven't solved it yet and I don't see a solution other than a lot of types and a lot of conversions. But I am very thankful for all the help.
My holidays just ended, so we'll see when I have time to deep dive into this again.