smithery.ai

form-mutation

Crée un formulaire React Hook Form connecté à une mutation Relay avec validation yup et gestion d'erreurs. Utiliser pour formulaires avec soumission GraphQL.

First seen Mar 19, 2026

Installation

$ npx skills add https://smithery.ai

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from smithery.ai · top by installs.

npx skills add https://smithery.ai

Browse all from smithery.ai

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 8,895 B
  • docs SUMMARY.md 181 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 1 installs

SKILL.md

Form + Relay Mutation

Crée un formulaire React Hook Form connecté à une mutation Relay avec les patterns du projet.

Toujours définir un schéma yup + resolver: yupResolver(schema), même pour un formulaire à un seul champ.
Ne pas se fier uniquement au prop isRequired de FormControl. FormControl et FieldInput appellent chacun
useController séparément pour le même name/control : FormControl enregistre la règle required,
FieldInput enregistre minLength/maxLength/pattern. Selon l'ordre de montage des effets, l'un peut
écraser silencieusement les règles de l'autre, ce qui rend isRequired seul non fiable (un champ vide peut
alors passer côté client et provoquer une erreur backend au lieu d'un message de validation).
Un resolver yup valide l'objet complet en un seul passage et alimente formState.errors directement,
indépendamment de ce risque de collision entre les deux useController.

Pour un champ texte requis, .required() ne suffit pas : une chaîne composée uniquement d'espaces
(" ") passe .required() (ce n'est pas une chaîne vide). Utiliser la méthode custom réutilisable
.notBlank(message) à la place — définie dans admin-next/shared/utils/yupExtensions.ts, à importer une
fois par fichier de schéma (import '@shared/utils/yupExtensions') pour que la méthode existe au runtime.
Voir REFONTE_SONATA.md pour le détail.

Structure de base

import { useForm, FormProvider } from 'react-hook-form'
import { yupResolver } from '@hookform/resolvers/yup'
import * as yup from 'yup'
import '@shared/utils/yupExtensions' // registers the notBlank() custom method
import { useIntl } from 'react-intl'
import { Button, Flex } from '@cap-collectif/ui'
import { FormControl, FieldInput } from '@cap-collectif/form'
import { mutationErrorToast } from '@utils/mutation-error-toast'
import UpdateEntityMutation from '@mutations/UpdateEntityMutation'

// 1. Form types
type FormValues = {
  title: string
  description: string | null
  isActive: boolean
}

// 2. Yup validation schema
const getSchema = (intl: IntlShape) =>
  yup.object().shape({
    title: yup
      .string()
      .notBlank(intl.formatMessage({ id: 'global.required' }))
      .min(2, intl.formatMessage({ id: 'global.min-length' }, { count: 2 })),
    description: yup.string().nullable(),
    isActive: yup.boolean(),
  })

// 3. Initial values from Relay data
const getInitialValues = (data: EntityFragment$data): FormValues => ({
  title: data.title,
  description: data.description ?? null,
  isActive: data.isActive,
})

// 4. Form component
export const EntityForm: React.FC<Props> = ({ entity }) => {
  const intl = useIntl()
  const [isSubmitting, setIsSubmitting] = React.useState(false)

  const methods = useForm<FormValues>({
    mode: 'onChange',
    defaultValues: getInitialValues(entity),
    resolver: yupResolver(getSchema(intl)),
  })

  const { handleSubmit, formState: { isValid } } = methods

  const onSubmit = (values: FormValues) => {
    setIsSubmitting(true)

    const input = {
      entityId: entity.id,
      title: values.title,
      description: values.description,
      isActive: values.isActive,
    }

    UpdateEntityMutation.commit({ input })
      .then(() => {
        toast({
          variant: 'success',
          content: intl.formatMessage({ id: 'global.changes.saved' }),
        })
      })
      .catch(() => {
        mutationErrorToast(intl)
      })
      .finally(() => {
        setIsSubmitting(false)
      })
  }

  return (
    <FormProvider {...methods}>
      <form onSubmit={handleSubmit(onSubmit)}>
        <FormControl name="title" control={methods.control} isRequired>
          <FormControl.Label>
            {intl.formatMessage({ id: 'global.title' })}
          </FormControl.Label>
          <FieldInput
            name="title"
            control={methods.control}
            type="text"
          />
        </FormControl>

        <FormControl name="description" control={methods.control}>
          <FormControl.Label>
            {intl.formatMessage({ id: 'global.description' })}
          </FormControl.Label>
          <FieldInput
            name="description"
            control={methods.control}
            type="textarea"
          />
        </FormControl>

        <Button
          type="submit"
          variant="primary"
          isLoading={isSubmitting}
          disabled={!isValid || isSubmitting}
        >
          {intl.formatMessage({ id: 'global.save' })}
        </Button>
      </form>
    </FormProvider>
  )
}

Validation conditionnelle

const schema = yup.object().shape({
  isExternal: yup.boolean(),
  externalLink: yup.string().when('isExternal', {
    is: true,
    then: yup
      .string()
      .required(intl.formatMessage({ id: 'global.required' }))
      .url(intl.formatMessage({ id: 'global.invalid-url' })),
    otherwise: yup.string().nullable(),
  }),
})

Pattern Hook Mutation (frontend/js)

Pour frontend/js/, utiliser le pattern hook :

// mutations/UpdateEntityMutation.ts
import { graphql, useMutation } from 'react-relay'
import type { UpdateEntityMutation as MutationType } from '@relay/UpdateEntityMutation.graphql'

const mutation = graphql`
  mutation UpdateEntityMutation($input: UpdateEntityInput!) {
    updateEntity(input: $input) {
      entity {
        id
        title
      }
      errorCode
    }
  }
`

export const useUpdateEntityMutation = () => {
  const [commit, isLoading] = useMutation<MutationType>(mutation)
  return { commit, isLoading }
}

// In component
const { commit, isLoading } = useUpdateEntityMutation()

const onSubmit = (values: FormValues) => {
  commit({
    variables: { input: { ...values, entityId: entity.id } },
    onCompleted: (response, errors) => {
      if (errors?.length || response.updateEntity?.errorCode) {
        mutationErrorToast(intl)
        return
      }
      successToast()
    },
    onError: () => mutationErrorToast(intl),
  })
}

Pattern Promise Mutation (admin-next)

Pour admin-next/, utiliser le pattern promise :

// mutations/UpdateEntityMutation.ts
import { graphql } from 'react-relay'
import { commitMutation } from './commitMutation'
import type { UpdateEntityMutation } from '@relay/UpdateEntityMutation.graphql'

const mutation = graphql`
  mutation UpdateEntityMutation($input: UpdateEntityInput!) {
    updateEntity(input: $input) {
      entity { id title }
      errorCode
    }
  }
`

const commit = (variables: UpdateEntityMutation['variables']) =>
  commitMutation<UpdateEntityMutation>({
    mutation,
    variables,
  })

export default { commit }

// In component
UpdateEntityMutation.commit({ input })
  .then(response => { ... })
  .catch(error => { ... })

Gestion d'erreurs manuelle

const { setError } = useFormContext()

const onSubmit = (values: FormValues) => {
  if (!values.address) {
    setError('address', {
      type: 'manual',
      message: intl.formatMessage({ id: 'address.required' }),
    })
    return
  }
  // ... mutation
}

Auto-save avec debounce

import { useDebounce } from 'use-debounce'

const { watch } = methods
const watchedValues = watch()
const [debouncedValues] = useDebounce(watchedValues, 500)

React.useEffect(() => {
  if (methods.formState.isDirty && methods.formState.isValid) {
    onSubmit(debouncedValues)
  }
}, [debouncedValues])

Types de champs FieldInput

<FieldInput type="text" />
<FieldInput type="textarea" />
<FieldInput type="number" />
<FieldInput type="email" />
<FieldInput type="password" />
<FieldInput type="select" options={[{ value: 'a', label: 'A' }]} />
<FieldInput type="checkbox" />
<FieldInput type="radio" choices={[...]} />
<FieldInput type="date" />
<FieldInput type="uploader" />  {/* File upload */}

Exemples du projet

  • Validation yup : [ProjectConfigForm.tsx](admin-next/components/BackOffice/Projects/ProjectConfig/ProjectConfigForm.tsx)
  • Hook mutation : [AddressRequirementModal.tsx](frontend/js/components/ParticipationWorkflow/AddressRequirementModal.tsx)
  • Promise mutation : [OrganizationConfigForm.tsx](admin-next/components/BackOffice/Organizations/OrganizationConfigForm.tsx)

Checklist

  • Types FormValues définis
  • Schéma yup avec messages i18n (obligatoire, même pour un seul champ requis — ne pas compter sur isRequired seul)
  • defaultValues depuis les données Relay
  • FormProvider enveloppe le form
  • Gestion isSubmitting / isLoading
  • Toast succès et erreur
  • Bouton submit disabled si invalide