Skip to content

Commit

Permalink
Merge pull request #19 from stevent-team/refactor/decouple-register-fn
Browse files Browse the repository at this point in the history
Decouple register fn from React rerenders
  • Loading branch information
GRA0007 authored Jul 10, 2023
2 parents 7aac992 + 739ac59 commit 00ff73a
Show file tree
Hide file tree
Showing 5 changed files with 73 additions and 44 deletions.
5 changes: 5 additions & 0 deletions .changeset/silver-bottles-wink.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@stevent-team/react-zoom-form": patch
---

Decouple register fn from useForm hook
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,10 @@ const LinkField = ({ field }: { field: Field<Link> }) => {
}
```

### Tips

- If you're computing your schema inside the react component that calls `useForm`, be sure to memoize the schema so rerenders of the component do not recalculate the schema. This also goes for `initialValues`.

## Contributing

You can install dependencies by running `yarn` after cloning this repo, and `yarn dev` to start the example.
Expand Down
42 changes: 41 additions & 1 deletion lib/field.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { z } from 'zod'
import { PathSegment, RecursivePartial, arrayStartsWith, getDeepProp, setDeepProp } from './utils'
import { PathSegment, RecursivePartial, arrayStartsWith, getDeepProp, isRadio, setDeepProp, unwrapZodType } from './utils'
import { FieldRefs } from './useForm'

export type FieldControls<Schema extends z.ZodType = z.ZodType> = {
schema: Schema
Expand Down Expand Up @@ -29,6 +30,45 @@ export type Field<T> = {
errors: z.ZodIssue[]
}

export type RegisterFn = (
path: PathSegment[],
schema: z.ZodType,
setFormValue: React.Dispatch<React.SetStateAction<RecursivePartial<z.ZodType>>>,
fieldRefs: React.MutableRefObject<FieldRefs>,
) => {
onChange: React.ChangeEventHandler<any>
ref: React.Ref<any>
name: string
}

// Register for native elements (input, textarea, select)
export const register: RegisterFn = (path, fieldSchema, setFormValue, fieldRefs) => {
const name = path.map(p => p.key).join('.')
const unwrapped = unwrapZodType(fieldSchema)

return {
onChange: e => {
let newValue: string | boolean | undefined = e.currentTarget.value
if (!(unwrapped instanceof z.ZodString) && newValue === '') {
newValue = undefined
}
if (e.currentTarget.type?.toLowerCase() === 'checkbox') {
newValue = e.currentTarget.checked
}
setFormValue(v => setDeepProp(v, path, newValue) as typeof v)
},
name,
ref: ref => {
if (ref) {
const refIndex = isRadio(ref) ? `${name}.${ref.value}` : name
fieldRefs.current[refIndex] = { path, ref }
} else {
delete fieldRefs.current[name]
}
},
} satisfies React.ComponentProps<'input'>
}

/**
* Control a custom field. Takes the field you want to control from
* `fields` given by the `useForm` hook, and returns an object with
Expand Down
50 changes: 12 additions & 38 deletions lib/useForm.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { z } from 'zod'

import { PathSegment, RecursivePartial, fieldChain, getDeepProp, setDeepProp, unwrapZodType, deepEqual, FormatSchemaFields, isCheckbox, isRadio } from './utils'
import { PathSegment, RecursivePartial, fieldChain, getDeepProp, deepEqual, FormatSchemaFields, isCheckbox, isRadio } from './utils'
import { RegisterFn, register } from './field'

export interface UseFormOptions<Schema extends z.AnyZodObject> {
/** The zod schema to use when parsing the values. */
/**
* The zod schema to use when parsing the values.
*
* @important
* If you're calculating this, be sure to memoize the value.
*/
schema: Schema
/** Initialise the fields with values. By default they will be set to undefined. */
initialValues?: RecursivePartial<z.infer<Schema>>
}

export type SubmitHandler<Schema extends z.AnyZodObject> = (values: z.infer<Schema>) => void

export type RegisterFn = (path: PathSegment[], schema: z.ZodType) => {
onChange: React.ChangeEventHandler<any>
ref: React.LegacyRef<any>
name: string
}
export type FieldRefs = Record<string, { path: PathSegment[], ref: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement }>

/**
* Hook used to control a form. Takes configuration options and returns an object with state and methods.
Expand All @@ -27,7 +29,7 @@ export const useForm = <Schema extends z.AnyZodObject>({
}: UseFormOptions<Schema>) => {
const [formValue, setFormValue] = useState(structuredClone(initialValues))
const [formErrors, setFormErrors] = useState<z.ZodError<z.infer<Schema>>>()
const fieldRefs = useRef<Record<string, { path: PathSegment[], ref: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement }>>({})
const fieldRefs = useRef<FieldRefs>({})

// Whether or not to validate fields when anything changes
const [validateOnChange, setValidateOnChange] = useState(false)
Expand Down Expand Up @@ -82,36 +84,8 @@ export const useForm = <Schema extends z.AnyZodObject>({
setValidateOnChange(true)
}, [validate])

// Register for native elements (input, textarea, select)
const register = useCallback<RegisterFn>((path, fieldSchema) => {
const name = path.map(p => p.key).join('.')
const unwrapped = unwrapZodType(fieldSchema)

return {
onChange: e => {
let newValue: string | boolean | undefined = e.currentTarget.value
if (!(unwrapped instanceof z.ZodString) && newValue === '') {
newValue = undefined
}
if (e.currentTarget.type?.toLowerCase() === 'checkbox') {
newValue = e.currentTarget.checked
}
setFormValue(v => setDeepProp(v, path, newValue) as typeof v)
},
name,
ref: ref => {
if (ref) {
const refIndex = isRadio(ref) ? `${name}.${ref.value}` : name
fieldRefs.current[refIndex] = { path, ref }
} else {
delete fieldRefs.current[name]
}
},
} satisfies React.ComponentProps<'input'>
}, [formValue])

const fields = useMemo(() => new Proxy(schema.shape, {
get: (_target, key) => fieldChain(schema, [], register, { formValue, setFormValue, formErrors })[key]
get: (_target, key) => fieldChain(schema, [], register, fieldRefs, { formValue, setFormValue, formErrors })[key]
}) as FormatSchemaFields<Schema, {
/**
* Provides props to pass to native elements (input, textarea, select)
Expand All @@ -127,7 +101,7 @@ export const useForm = <Schema extends z.AnyZodObject>({
* <label htmlFor={field.firstName.name()}>First name</label>
*/
name: () => string
}>, [schema, register, formValue, formErrors])
}>, [schema, formValue, formErrors])

return {
/** Access zod schema and registration functions for your fields. */
Expand Down
16 changes: 11 additions & 5 deletions lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FieldControls, RegisterFn } from '.'
import { FieldControls, FieldRefs, RegisterFn } from '.'
import { z } from 'zod'

// Creates the type for the field chain
Expand Down Expand Up @@ -44,7 +44,13 @@ export type PathSegment = {
*
* Thanks to [react-zorm](https://github.com/esamattis/react-zorm) for the inspiration.
*/
export const fieldChain = <S extends z.ZodType>(schema: S, path: PathSegment[], register: RegisterFn, controls: Omit<FieldControls<z.ZodTypeAny>, 'schema' | 'path'>): any =>
export const fieldChain = <S extends z.ZodType>(
schema: S,
path: PathSegment[],
register: RegisterFn,
fieldRefs: React.MutableRefObject<FieldRefs>,
controls: Omit<FieldControls<z.ZodTypeAny>, 'schema' | 'path'>,
): any =>
new Proxy({}, {
get: (_target, key) => {
if (key === Symbol.toStringTag) return schema.toString()
Expand All @@ -67,16 +73,16 @@ export const fieldChain = <S extends z.ZodType>(schema: S, path: PathSegment[],

// Support arrays
if (unwrapped instanceof z.ZodArray && !isNaN(Number(key))) {
return fieldChain(unwrapped._def.type, [...path, { key: Number(key), type: 'array' }], register, controls)
return fieldChain(unwrapped._def.type, [...path, { key: Number(key), type: 'array' }], register, fieldRefs, controls)
}

if (!(unwrapped instanceof z.ZodObject)) {
if (key === 'register') return () => register(path, schema)
if (key === 'register') return () => register(path, schema, controls.setFormValue, fieldRefs)
if (key === 'name') return () => path.map(p => p.key).join('.')
throw new Error(`Expected ZodObject at "${path.map(p => p.key).join('.')}" got ${schema.constructor.name}`)
}

return fieldChain(unwrapped.shape[key], [...path, { key, type: 'object' }], register, controls)
return fieldChain(unwrapped.shape[key], [...path, { key, type: 'object' }], register, fieldRefs, controls)
},
}) as unknown

Expand Down

0 comments on commit 00ff73a

Please sign in to comment.