remix-hook-form icon indicating copy to clipboard operation
remix-hook-form copied to clipboard

Controlled react-select input not being captured during submition

Open semet opened this issue 1 year ago • 3 comments

i use remix-hook-fom in my project. I struggled trying to use it with React Select. here is my controlled React Select code:

import { useId } from 'react'
import { Controller, FieldError, get } from 'react-hook-form'
import ReactSelect from 'react-select'
import { useRemixFormContext } from 'remix-hook-form'
import { useHydrated } from 'remix-utils/use-hydrated'
import { twMerge } from 'tailwind-merge'

import { CustomOption } from './custom-option'
import { SelectProps } from './type'

export const Select = <T extends Record<string, unknown>>(
  props: SelectProps<T>
) => {
  const isHydrated = useHydrated()
  const {
    name,
    id,
    label,
    onChange,
    className,
    containerClassName,
    errorClassName,
    disabled,
    labelClassName,
    required,
    isSearchable = false,
    size = 'md',
    ...rest
  } = props
  const generatedId = useId()

  const {
    control,
    formState: { errors }
  } = useRemixFormContext()

  const error: FieldError = get(errors, name)
  return (
    <div
      className={twMerge([
        'relative flex w-full flex-col gap-1.5',
        containerClassName
      ])}
    >
      {label && (
        <label
          htmlFor={id ?? generatedId}
          className={twMerge(['text-gray-700', labelClassName])}
        >
          {label} {required && <span className="text-rose-500">*</span>}
        </label>
      )}
      {isHydrated ? (
        <Controller
          name={name}
          control={control}
          render={({ field }) => {
            return (
              <ReactSelect
                instanceId={id ?? generatedId}
                components={{ Option: CustomOption }}
                onChange={(newValue, actionMeta) => {
                  if (onChange) {
                    onChange(newValue, actionMeta)
                  }

                  field.onChange(newValue)
                }}
                value={field.value}
                isSearchable={isSearchable}
                isDisabled={disabled}
                className={className}
                
                {...rest}
              />
            )
          }}
        />
      ) : (
        <div className="h-8 w-full animate-pulse rounded-md bg-gray-300" />
      )}
      {error && (
        <span
          className={twMerge([
            errorClassName,
            'absolute -bottom-4 text-xs text-rose-500'
          ])}
        >
          {error?.message?.toString()}
        </span>
      )}
    </div>
  )
}

and here is my implementation:

export const createDepositSchema = z.object({
     bank: z.object({
         label: z.string(),
         value: z.string()
    })
})
  const fetcher = useFetcher()

  const formMethods = useRemixForm<TCreateDeposit>({
    mode: 'onSubmit',
    resolver: zodResolver(createDepositSchema),
    stringifyAllValues: false,
    fetcher
  })

   const {
     bank: watchedBank,
   } = watch()
   
   console.log(watchedBank) // the value is captured here
   
  <RemixFormProvider {...formMethods}>
        <fetcher.Form
          method="POST"
          action="/actions/deposit"
        >
            <Select<TCreateDeposit>
              required
              labelClassName="text-white"
              label="Bank Transfer"
              name="bank"
              options={[
                   {label: 'BNI', value: '1'},
                   {label: 'BCA', value: '2'},
                   {label: 'BRI', value: '3'},
                   {label: 'Mandiri', value: '4'},
             ]}
            />
        </fetcher.Form>
  </RemixFormProvider>

and here is my action:

import { zodResolver } from '@hookform/resolvers/zod'
import { ActionFunctionArgs } from '@remix-run/node'
import { getValidatedFormData } from 'remix-hook-form'

import { createDepositSchema, TCreateDeposit } from '@/schemas/deposit'

export const action = async ({ request }: ActionFunctionArgs) => {
  const {
    errors,
    data: formData,
    receivedValues: defaultValues
  } = await getValidatedFormData<TCreateDeposit>(
    request,
    zodResolver(createDepositSchema),
    true
  )
  if (errors) {
    console.log(errors)
    return Response.json(
      { success: false, errors, defaultValues },
      { status: 400 }
    )
  }

  console.log(formData)

  return null
}

when i try to console.log the value in the client, it is there as expected. but once i submit the form, it is not being captured in the action by getValidatedFormData and returns validation error. I try to debug it by adding .optional() to the schema, it turns out that the data is empty. the bank field didn't event sent to the action.

before submitting this issue, i have tried all possible options in. stringifyAllValues as well as in preserveStringified, but nothing happens.

in another remix roject where i use plain useForm with useFormContext from React Hook Form, it works just fine.

semet avatar Nov 14 '24 07:11 semet

To be completely honest here I have never really looked into if Controller from react-hook-form works with this library, but what you can do is just not use it and use setValue directly in the react-select instead and that will definitely work, eg:

{isHydrated ? (
     <ReactSelect
                instanceId={id ?? generatedId}
                components={{ Option: CustomOption }}
                onChange={(newValue, actionMeta) => {
                  if (onChange) {
                    onChange(newValue, actionMeta)
                    // here
                    setValue(name, newValue)
                  }

                  field.onChange(newValue)
                }}
                value={field.value}
                isSearchable={isSearchable}
                isDisabled={disabled}
                className={className}
                
                {...rest}
              />
      ) : (
        <div className="h-8 w-full animate-pulse rounded-md bg-gray-300" />
      )}

AlemTuzlak avatar Nov 14 '24 09:11 AlemTuzlak

Hi there, I am having some issues with RHF Controller as well and would like to ask if it is supported and if not, what the advized alternative would be.

The example below uses aria-components and is producing A component changed from uncontrolled to controlled. warnings and is basically making the input unusable.

Code based on https://react-spectrum.adobe.com/react-aria/forms.html#react-hook-form

          <Controller
            name="language.name"
            control={control}
            render={({ field, fieldState: { invalid, error } }) =>  (
                <TextField {...field} isInvalid={invalid}>
                  <TextField.Label>Name</TextField.Label>
                  <TextField.Input {...register(field.name)} />
                  <TextField.FieldError>{error?.message} </TextField.FieldError>
                </TextField>
              )
            }
          />

bravo-kernel avatar Dec 20 '24 21:12 bravo-kernel

FWIW RHF Controller works as expected, as long as we do not forget to pass defaultValues 🤦‍♀️

bravo-kernel avatar Dec 21 '24 11:12 bravo-kernel