Hvordan lagre kontinuerlig med react-hook-form

Jobber du med ny nettside, ny web app eller nytt digitalt produkt i React og trenger kontinuerlig lagring av skjemaer? Her er en kjapp oppskrift på hvordan du kan gjøre det med react-hook-form og en hook useDebounceFn.

I Ur Solutions har vi den siste tiden begynt å bruke react-hook-form på en del nye nettsider og webapper. Det er et moderne bibliotek som kobler seg input-verdiene som ligger på DOM, istedenfor den idiomatiske “controlled input” i React. Istedenfor at man har kode som ser noenlunde slik ut…


const [form, setForm] = useState<MySchema>({
	valueOne: "one",
	valueTwo: 2,
})

const onSubmit = (e: SubmitEvent) => {
	e.preventDefault()
	
	validateFormData(form)
	callMyApi(form)
}

return (
	<form>
		<input value={form.valueOne} />
		<input value={form.valueTwo} />
	</form>
)

… har man kode som ser noenlunde slik ut:


import { register } from 'react-hook-form'

// ...

const { register, handleSubmit } = useForm<MySchema>({
	defaultvalues: {
		valueOne: "one",
		valueTwo: 2,
	}
})

const onSubmit = (data: MySchema) => {
	callMyApi(form)
}

return (
	<form onSubmit={handlesubmit(onSubmit)}>
		<input {...register("valueOne")} />
		<input {...register("valueTwo")} />
	</form>
)

Om du trenger validering, kan dette gjøres direkte på inputtene (via HTML sin standard for validering):


import { register } from 'react-hook-form'

// ...

const { register, handleSubmit } = useForm<MySchema>({
	defaultvalues: {
		valueOne: "one",
		valueTwo: 2,
	}
})

const onSubmit = (data: MySchema) => {
	callMyApi(form)
}

return (
	<form onSubmit={handlesubmit(onSubmit)} >
		<input {...register("valueOne", { required: true })}/>
		<input {...register("valueTwo", { pattern: /^Ur/i })} />
	</form>
)

En utfordring jeg nettopp opplevde var at jeg hadde et skjema med behov for kontinuerlig lagring av dataen ettersom at skjemaet ble oppdatert av brukeren. I idiomatisk controlled-component-React, ville jeg gjort noe som dette:


const [form, setForm] = useState<MySchema>({
	valueOne: "one",
	valueTwo: 2,
})

const debouncedForm = useDebounce(form, 1000)

useEffect(() => { onSubmit(debouncedForm) }, [debouncedForm])

const onSubmit = () => {
	validateFormData(form)
	callMyApi(form)
}

return (
	<form>
		<input value={form.valueOne} />
		<input value={form.valueTwo} />
	</form>
)

Hooken useDebounce fungerer som en “throttle”, og gir deg maksimalt én ny verdi per, i dette tilfellet, 1000 ms. Du kan få hooken f.eks. fra https://github.com/streamich/react-use. Dette er nødvendig så vi ikke kaller APIet vårt hver gang f.eks. en ny bokstav skrives inn. Vi får en effekt hvor vi batcher oppdateringer og sender de av gårde eksempelvis én gang i sekundet.

Utfordringen med denne metodikken er at vi ikke har direkte tilgang på skjemadataen når vi bruker react-hook-form. Heldigvis har de en måte å høre etter nye data på via attributten watch.

Den kan brukes på to måter. Enten til å returnere verdien til skjemaet akkurat når den kalles…


const { register, handleSubmit, watch } = useForm<MySchema>({
	defaultvalues: {
		valueOne: "one",
		valueTwo: 2,
	}
})

const currentValueOfValueOne = watch("valueOne")
const allCurrentValues = watch()
const bothValues = watch(["valueOne", "valueTwo"])

eller i som en subscription…


const { register, handleSubmit, watch } = useForm<MySchema>({
	defaultvalues: {
		valueOne: "one",
		valueTwo: 2,
	}
})

useEffect(() => {
	const subscription = watch((newValue, { name, type }) =>  {
		// Do something with the new value
	})
}, [watch])

Dette gir oss muligheten til å få til kontinuerlig lagring med react-hook-form:


const { register, handleSubmit, watch } = useForm<MySchema>({
	defaultvalues: {
		valueOne: "one",
		valueTwo: 2,
	}
})

const allCurrentValues = watch()
const debouncedValues = useDebounce(allCurrentValues, 1000)

useEffect(() => {
	callMyApi(debouncedValues)
}, [debouncedValues])

Flott, så dette er det som skal til? Ikke så fort.

Problemet her er at watch ikke gjennomfører skjemavalidering. Med react-hook-form har vi sannsynligvis avlastet validering til HTMLen, eller via et skjemavalideringsverktøy som Zod.

En måte å fikse dette på kan være å flytte valideringen tilbake til Javascript/Typescript med en validateFormData metode. Men det finnes en enklere måte.

Vi kan bruke subscription-versjonen av watch og lage en ny submit event inne i skjemaet direkte:


const { register, handleSubmit, watch } = useForm<MySchema>({
	defaultvalues: {
		valueOne: "one",
		valueTwo: 2,
	}
})

const { run } = useDebounceFn(() => {
  formRef.current?.dispatchEvent(
    new Event('submit', { cancelable: true, bubbles: true })
  )
}, 1000)

useEffect(() => {
  const subscription = watch(() => run())

  return () => {
    subscription.unsubscribe()
  }
}, [run, watch])

Denne koden gjør det den skal:

  • Den sender kontinuerlig updates.
  • Den gjennomfører skjemavalidering.
  • Den batcher/throttler oppdateringer.

useDebounceFn er en callback-variant av useDebounce. Det finnes flere mulige implementasjoner av denne. Her er den vi bruker:


import { useEffect, useRef } from 'react'

export function useDebounceFn<T extends any[]>(
  fn: (...args: T) => void,
  wait: number
) {
  const fnRef = useRef(fn)
  fnRef.current = fn

  const timeoutRef = useRef<NodeJS.Timeout | null>(null)
  const argsRef = useRef<T | null>(null) // Store arguments

  const cancelDebouncedFn = () => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current)
    }
  }

  const debouncedFn: (...args: T) => void = (...args: T) => {
    argsRef.current = args // Store arguments when debouncedFn gets called
    cancelDebouncedFn()
    timeoutRef.current = setTimeout(() => {
      if (argsRef.current) {
        // Check if args have been stored
        fnRef.current(...argsRef.current)
      }
    }, wait)
  }

  const flushDebouncedFn = () => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current)
      if (argsRef.current) {
        // Check if args have been stored
        fnRef.current(...argsRef.current) // Use stored args
      }
    }
  }

  useEffect(() => {
    return cancelDebouncedFn
  }, [])

  return {
    run: debouncedFn,
    cancel: cancelDebouncedFn,
    flush: flushDebouncedFn,
  }
}

Du kan lese mer om React-hook-form på deres nettsider.

Skrevet av
Tormod Haugland

Andre artikler