reactjscheckboxzoduse-form

How to store the selected checkboxes value in a dynamic form using Zod?


I am using zod for a form in a React app. In the form, there is a search textbox. You can type the search key words into that textbox to search for web sites and the search result (name of web sites) will be displayed as checkboxes. Then you can select the web sites you want by checking these checkboxes. I simply want to store the web sites url that have been checked in an array of string.

Usually, when you use a checkbox with zod, the value is a boolean (checked or not). I don't know how to store string data (web site url) from a checkbox. Also, the form is dynamic because if you search for, let say "sports", you get checkboxes for sports websites. If you change the search keywords for "news", the sports checkboxes will be replaced for "news" web sites, but the selected "sports" web site urls must have been stored somewhere.

You can see here the zod validation schema I am using.

const zodValidationSchema = z.object({
   zodSitesUrls: z.string()
     .array()
     .min(1, { message: "You must choose at least one web site" })
 });

There you can see that I am using map to display the checkboxes. This is working fine! But I am wondering how to store the web sites url...

{webSites?.map((ns, index) => (
              <Controller
                name={`zodSitesUrls.${index}`}
                control={control}
                render={({ field }) =>
                  <Checkbox {...field}
                    label={ns.siteName}
                    onChange={(e, isChecked) => onChangeWebSite(e, isChecked, ns.siteUrl)}
                  />
                }
               />
      )}

I could use the onChange checkboxes event of the selected web site url, but I don't know how to store the url into the zod schema.

If you can give me any tips or suggestions on how to do that, I would be happy! Thanks a lot in advance!

EDIT:

I found part of the solution. Here's what I found:

               <Controller
                name={`zodNewsSitesUrls.${index}`}
                aria-describedby={`newsSiteToolTip-${index}`}
                control={control}
                render={({ field }) =>
                  <Checkbox {...field}
                    className={styles.newsSiteCheckbox}
                    label={ns.siteName}
                    aria-describedby={`newsSiteToolTip-${index}`}
                   !== -1}
                    onChange={(e, isChecked) => {
                      field.onChange(isChecked ? ns.siteUrl : "");
                    }}
                  />
                }
              />

I changed the onChange method. Now I can assign a value to the zodSitesUrls object. The problem that I still have is if I check, let say, the first checkbox, then change the search keyword, then check again the first checkbox, the first element of the array will be overwritten because the first object of any search result always has the same name (zodNewsSitesUrls.${index}). So when the search results are changed, the previous search results are forgotten...


Solution

  • The problem you are having modelling this is because you are assuming a one-to-one mapping between the reference to form state (name) and the input "control" (checkbox).

    In your case, the conceptual mapping between the checkbox and the form state that holds it should be many-to-one. It makes no sense to map an individual checkbox to its own addressable state item because the checkbox state is actually just a function of what is and isn't present in zodSitesUrls. When a URL is not present, then it simply has no corresponding entry in zodSitesUrls. So there is no state for an "unchecked" checkbox to bind to. That's what's leading to the issues where you assume there is some mapping between the index of the array and each checkbox. No such stable mapping exists.

    So, we can have one controller connected to zodSitesUrls and multiple checkboxes within that controller.

    We override the onChange logic to add/remove the relevant URL to zodSitesUrls depending on whether it is checked or not.

    We also override value logic so that it shows as ticked if the relevant URL is present in zodSitesUrls.

    You did not specify the library Checkbox is from. It may use something other than value to control whether it is checked. I have assumed from your original question that the second arg to onChange is indeed isChecked. Exactly how these things are bound is library-specific.

    Also, the form is dynamic because if you search for, let say "sports", you get checkboxes for sports websites. If you change the search keywords for "news", the sports checkboxes will be replaced for "news" web sites, but the selected "sports" web site urls must have been stored somewhere.

    With this solution, this will work fine. If the checkboxes inside of the controller change, it won't matter. Since everything is derived from the core URL list, and the value of that list will not need to change when different checkboxes are rendered, it will be independent of this.

    That means you can render a set of checkboxes, populate the list using those, render an entirely new set, further populate the list even more, go back to the original set and remove some stuff -- etc.

    <Controller
      name="zodSitesUrls"
      control={control}
      render={({ field }) =>
        webSites?.map((ns, index) => (
          <Checkbox
            key={ns.siteUrl}
            label={ns.siteName}
            {...field}
            
            // Override default field.onChange
            onChange={(e, isChecked) => {
              // Checked case
              if (isChecked) {
                // Already in list, do nothing
                if (field.value.includes(ns.siteUrl)) return
                
                // Add entry to array, without losing existing entries
                field.onChange([...field.value, ns.siteUrl])
                
                return
              }
            
              // Unchecked case
              // Filter this value out from list (doesn't matter if it was never here anyway)
              field.onChange(field.value.filter(url => url !== ns.siteUrl))
            }}
            
            // Override default field.value
            value={field.value.includes(ns.siteUrl)}
          />
        ))
      }
    />
    
    

    You should also ensure in the defaultValues of the form configuration that zodSitesUrls is an array. E.g. zodSitesUrls: [].