Last active
June 10, 2025 04:39
-
-
Save xantiagoma/7716a67e380b5bc9414e3d24b5070273 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { | |
FormikState, | |
getIn, | |
FormikValues, | |
FieldConfig, | |
FieldInputProps, | |
FormikErrors, | |
FormikTouched, | |
FormikProps, | |
useFormik, | |
} from 'formik'; | |
import { SetStateAction } from 'react'; | |
/** | |
* createSubFormik | |
* | |
* Builds a "sub‐Formik" proxy that looks and behaves *exactly* like a `useFormik(...)` return value, | |
* but all the nested‐field calls (e.g. setFieldValue, getFieldProps, validateField, etc.) | |
* are internally rewritten to target a nested `basePath`. | |
* | |
* @template TParentValues The full parent form `values` type | |
* @template TSubValues The nested object type at `basePath` | |
* | |
* @param parentFormik The FormikProps<TParentValues> instance returned by useFormik<TParentValues>(). | |
* @param basePath A Lodash‐style path string (e.g. "submissionMedias[2]" or "users['alice'].profile") | |
* pointing to a nested object in `parentFormik.values`. | |
* @returns A FormikProps<TSubValues>‐shaped object, with every property of the original | |
* FormikProps, but internally prefixing nested field calls by `basePath`. | |
*/ | |
export function createSubFormik< | |
TParentValues extends FormikValues, | |
TSubValues extends FormikValues | |
>( | |
parentFormik: FormikProps<TParentValues>, | |
basePath: string | |
): ReturnType<typeof useFormik<TSubValues>> { | |
// —————————————————————————————————————————————————————————————— | |
// 1. Read nested "current" values/errors/touched via getIn() | |
// This safely digs into `parentFormik.values[basePath]`, even if intermediate objects are undefined. | |
// —————————————————————————————————————————————————————————————— | |
const subValues = getIn(parentFormik.values, basePath) as TSubValues; | |
const subErrors = (getIn(parentFormik.errors, basePath) ?? | |
{}) as FormikErrors<TSubValues>; | |
const subTouched = (getIn(parentFormik.touched, basePath) ?? | |
{}) as FormikTouched<TSubValues>; | |
// —————————————————————————————————————————————————————————————— | |
// 2. Likewise read the "initialX" variants from the parent's initial state | |
// so that subFormik.initialValues === initial parentFormik.values[basePath]. | |
// —————————————————————————————————————————————————————————————— | |
const subInitialValues = getIn( | |
parentFormik.initialValues, | |
basePath | |
) as TSubValues; | |
const subInitialErrors = (getIn(parentFormik.initialErrors, basePath) ?? | |
{}) as FormikErrors<TSubValues>; | |
const subInitialTouched = (getIn(parentFormik.initialTouched, basePath) ?? | |
{}) as FormikTouched<TSubValues>; | |
const subInitialStatus = parentFormik.initialStatus | |
? (getIn(parentFormik.initialStatus, basePath) as any) | |
: undefined; | |
// —————————————————————————————————————————————————————————————— | |
// 3. Build the proxy object. Every property in FormikProps<TSubValues> must be present. | |
// When a method takes a `field: string` (e.g. "foo"), we prepend `${basePath}.` so that | |
// it truly targets `parentFormik.values[basePath].foo`. | |
// —————————————————————————————————————————————————————————————— | |
return { | |
// ——— Read‐Only "initial" props —————————————————————————————————————— | |
initialValues: subInitialValues, | |
initialErrors: subInitialErrors as FormikErrors<unknown>, | |
initialTouched: subInitialTouched as FormikTouched<unknown>, | |
initialStatus: subInitialStatus ?? undefined, | |
// ——— Core event‐handlers ————————————————————————————————————————————— | |
/** | |
* handleBlur: | |
* If passed an event, we rewrite e.target.name = `${basePath}.${originalName}`. | |
* If passed a string, assume it's a "field name" (not typically used by children). | |
*/ | |
handleBlur: ((eOrField: unknown) => { | |
if (typeof eOrField === 'string') { | |
// If child calls subFormik.handleBlur("someField"), this simply maps to parentFormik.handleBlur("submissionMedias[2].someField") | |
(parentFormik.handleBlur as (field: string) => void)( | |
`${basePath}.${eOrField}` | |
); | |
} else { | |
// If child calls subFormik.handleBlur(event), rewrite event.target.name accordingly | |
const e = eOrField as React.FocusEvent<unknown>; | |
(parentFormik.handleBlur as (e: React.FocusEvent<unknown>) => void)({ | |
...e, | |
target: { | |
...e.target, | |
name: `${basePath}.${ | |
(e.target as unknown as { name: string }).name | |
}`, | |
}, | |
} as React.FocusEvent<unknown>); | |
} | |
}) as FormikProps<TSubValues>['handleBlur'], | |
/** | |
* handleChange: | |
* If passed an event, rewrite e.target.name to `${basePath}.${originalName}`. | |
* If passed a string (unlikely in most Formik usage), we can forward parentFormik.handleChange(name). | |
*/ | |
handleChange: ((eOrField: unknown) => { | |
if (typeof eOrField === 'string') { | |
(parentFormik.handleChange as (field: string) => void)( | |
`${basePath}.${eOrField}` | |
); | |
} else { | |
const e = eOrField as React.ChangeEvent<unknown>; | |
const target = e.target as unknown as { | |
name: string; | |
type: string; | |
value: unknown; | |
checked: boolean; | |
}; | |
const { name, type, value, checked } = target; | |
(parentFormik.handleChange as (e: React.ChangeEvent<unknown>) => void)({ | |
...e, | |
target: { | |
...e.target, | |
name: `${basePath}.${name}`, | |
value: type === 'checkbox' ? checked : value, | |
}, | |
} as React.ChangeEvent<unknown>); | |
} | |
}) as FormikProps<TSubValues>['handleChange'], | |
/** | |
* handleReset & handleSubmit: forwarded directly from parent. This means if the child calls | |
* `subFormik.handleSubmit(e)`, it will run the parent's onSubmit with the full values, not just sub-values. | |
* That is consistent with "one Formik context" for the entire form. | |
*/ | |
handleReset: parentFormik.handleReset, | |
handleSubmit: parentFormik.handleSubmit, | |
/** | |
* resetForm(): forwarded directly; resets the entire parent form. | |
*/ | |
resetForm: (nextState?: Partial<FormikState<TSubValues>>) => { | |
// For resetForm, we can't easily scope to just the sub-section, so we reset the entire form | |
// and optionally set the nested state if provided | |
if (nextState) { | |
// This is a complex operation - we'd need to merge the partial sub-state into the full parent state | |
// For now, we'll reset the entire form and then set the sub-values if provided | |
parentFormik.resetForm(); | |
if (nextState.values) { | |
parentFormik.setFieldValue(basePath, nextState.values, false); | |
} | |
} else { | |
parentFormik.resetForm(); | |
} | |
}, | |
// ——— Imperative state‐setters ————————————————————————————————————— | |
setErrors: (errs: FormikErrors<TSubValues>) => { | |
// Merge the child's errors under basePath back into the parent's full errors object | |
parentFormik.setErrors({ | |
...parentFormik.errors, | |
[basePath]: errs, | |
} as unknown as FormikErrors<TParentValues>); | |
}, | |
/** | |
* setFormikState: | |
* Allows child to do `subFormik.setFormikState(fnOrState)`. We forward it to parentFormik.setFormikState, | |
* which modifies the entire FormikState<TParentValues>. The child is responsible for only mutating | |
* its nested portion if desired. We do not rewrite the callback, because catching all possible user‐side | |
* mutations is extremely complex; instead, we forward "as is." | |
*/ | |
setFormikState: ( | |
stateOrCb: | |
| FormikState<TSubValues> | |
| ((state: FormikState<TSubValues>) => FormikState<TSubValues>) | |
) => { | |
// This is inherently unsafe but necessary for the proxy pattern | |
parentFormik.setFormikState( | |
stateOrCb as unknown as | |
| FormikState<TParentValues> | |
| ((state: FormikState<TParentValues>) => FormikState<TParentValues>) | |
); | |
}, | |
/** | |
* setFieldTouched(field, touched?, shouldValidate?): | |
* We rewrite `${basePath}.${field}` under the hood. | |
*/ | |
setFieldTouched: ( | |
field: string, | |
touched: boolean = true, | |
shouldValidate?: boolean | |
): Promise<FormikErrors<TSubValues>> | Promise<void> => | |
parentFormik.setFieldTouched( | |
`${basePath}.${field}`, | |
touched, | |
shouldValidate | |
) as Promise<FormikErrors<TSubValues>> | Promise<void>, | |
/** | |
* setFieldValue(field, value, shouldValidate?): | |
* We rewrite it to target `parentFormik.values[basePath].fieldName`. | |
*/ | |
setFieldValue: ( | |
field: string, | |
value: unknown, | |
shouldValidate?: boolean | |
): Promise<FormikErrors<TSubValues>> | Promise<void> => | |
parentFormik.setFieldValue( | |
`${basePath}.${field}`, | |
value, | |
shouldValidate | |
) as Promise<FormikErrors<TSubValues>> | Promise<void>, | |
/** | |
* setFieldError(field, message): | |
* Rewrites to `parentFormik.setFieldError("submissionMedias[2].someField", message)` | |
*/ | |
setFieldError: (field: string, message: string | undefined) => | |
parentFormik.setFieldError(`${basePath}.${field}`, message), | |
/** | |
* setStatus(status): | |
* Status is a top-level concept. We forward directly to parentFormik.setStatus. | |
*/ | |
setStatus: (status: unknown) => parentFormik.setStatus(status), | |
/** | |
* setSubmitting(isSubmitting): | |
* A top-level boolean; forwarded as‐is. | |
*/ | |
setSubmitting: (isSubmitting: boolean) => | |
parentFormik.setSubmitting(isSubmitting), | |
/** | |
* setTouched(touched, shouldValidate?): | |
* We merge the child's "touched" object under basePath into the parent's `touched`: | |
* parentFormik.touched = { … parentFormik.touched, [basePath]: touched } | |
*/ | |
setTouched: ( | |
touched: FormikTouched<TSubValues>, | |
shouldValidate?: boolean | |
): Promise<FormikErrors<TSubValues>> | Promise<void> => { | |
return parentFormik.setTouched( | |
{ | |
...parentFormik.touched, | |
[basePath]: touched, | |
} as unknown as FormikTouched<TParentValues>, | |
shouldValidate | |
) as Promise<FormikErrors<TSubValues>> | Promise<void>; | |
}, | |
/** | |
* setValues(values, shouldValidate?): | |
* If the child does subFormik.setValues(newSubValues), we must place those new subValues at | |
* parentFormik.values[basePath]. We assume "values" is either TSubValues or a callback | |
* (prevSub => TSubValues). We read current parent values, replace the slice at basePath, | |
* then call parentFormik.setValues(newParentValues). | |
*/ | |
setValues: ( | |
vals: SetStateAction<TSubValues>, | |
shouldValidate?: boolean | |
): Promise<FormikErrors<TSubValues>> | Promise<void> => { | |
// Extract current subValues from parentFormik.values | |
const currentParent = parentFormik.values; | |
const currentSub = getIn(currentParent, basePath) as TSubValues; | |
// Compute nextSub = typeof vals === 'function' ? vals(currentSub) : vals | |
const nextSub = | |
typeof vals === 'function' | |
? (vals as (prev: TSubValues) => TSubValues)(currentSub) | |
: vals; | |
// Now rebuild a brand‐new parent values object with updated nested path | |
// We can't just do `{ ...currentParent, [basePath]: nextSub }` because basePath may be deep. | |
// Instead, rely on Formik's setFieldValue for the entire object under basePath: | |
return parentFormik.setFieldValue(basePath, nextSub, shouldValidate) as | |
| Promise<FormikErrors<TSubValues>> | |
| Promise<void>; | |
}, | |
// ——— Submission & Validation ————————————————————————————————————————— | |
/** | |
* submitForm(): forwarded directly; runs the parent's submission logic. | |
*/ | |
submitForm: parentFormik.submitForm, | |
/** | |
* validateForm(values?): | |
* If the child passes new sub-values to validate, we would need to reconstruct a full parent | |
* values object to run validation. For simplicity, if `values` is omitted, we forward directly. | |
* If `values` is provided (as TSubValues), you'd have to do the same "re‐embedding" we do in setValues. | |
* In most usage (child calls subFormik.validateForm() with no args), forwarding is fine. | |
*/ | |
validateForm: (vals?: TSubValues): Promise<FormikErrors<TSubValues>> => { | |
if (typeof vals === 'undefined') { | |
return parentFormik.validateForm() as Promise<FormikErrors<TSubValues>>; | |
} | |
// Build a new "parentValuesCandidate" by injecting `vals` at basePath into parentFormik.values | |
parentFormik.setFieldValue(basePath, vals, false); | |
return parentFormik.validateForm() as Promise<FormikErrors<TSubValues>>; | |
}, | |
/** | |
* validateField(name): | |
* Internally calls parentFormik.validateField(`${basePath}.${name}`) | |
*/ | |
validateField: (name: string) => | |
parentFormik.validateField(`${basePath}.${name}`), | |
// ——— Field Registration ————————————————————————————————————————————— | |
/** | |
* registerField(name, opts): | |
* Prefix to `${basePath}.${name}`, so the parentFormik registers nested field metadata properly. | |
*/ | |
registerField: | |
parentFormik.registerField as FormikProps<TSubValues>['registerField'], | |
/** | |
* unregisterField(name): | |
* Prefix likewise. | |
*/ | |
unregisterField: (name: string) => { | |
parentFormik.unregisterField(`${basePath}.${name}`); | |
}, | |
// ——— Field Helpers & Meta ———————————————————————————————————————— | |
/** | |
* getFieldProps(nameOrOptions): | |
* If `nameOrOptions` is a string, rewrite `name = \`${basePath}.${nameOrOptions}\``. | |
* If it's a FieldConfig object, clone it but replace `.name` with `\`${basePath}.${.name}\``. | |
*/ | |
getFieldProps: (<Value = unknown>( | |
nameOrOptions: string | FieldConfig<Value> | |
): FieldInputProps<Value> => { | |
if (typeof nameOrOptions === 'string') { | |
return parentFormik.getFieldProps(`${basePath}.${nameOrOptions}`); | |
} | |
// If it's an object like { name: 'foo', type: 'text', ... }, rewrite the `name` field | |
const opts = nameOrOptions as FieldConfig<Value>; | |
return parentFormik.getFieldProps({ | |
...opts, | |
name: `${basePath}.${opts.name}`, | |
}); | |
}) as FormikProps<TSubValues>['getFieldProps'], | |
/** | |
* getFieldMeta(name): | |
* Simply prefixes to read nested error/meta state. | |
*/ | |
getFieldMeta: (name: string) => | |
parentFormik.getFieldMeta(`${basePath}.${name}`), | |
/** | |
* getFieldHelpers(name): | |
* Prefix to call parentFormik.getFieldHelpers(`${basePath}.${name}`). | |
*/ | |
getFieldHelpers: (name: string) => | |
parentFormik.getFieldHelpers(`${basePath}.${name}`), | |
// ——— Booleans & Read‐Only Flags —————————————————————————————————————— | |
isValid: parentFormik.isValid, | |
dirty: parentFormik.dirty, | |
isSubmitting: parentFormik.isSubmitting, | |
isValidating: parentFormik.isValidating, | |
submitCount: parentFormik.submitCount, | |
// ——— Validation Flags ————————————————————————————————————————————— | |
validateOnBlur: parentFormik.validateOnBlur ?? true, | |
validateOnChange: parentFormik.validateOnChange ?? true, | |
validateOnMount: parentFormik.validateOnMount ?? false, | |
// ——— Live Values/Errors/Touched/Status ————————————————————————————————— | |
values: subValues, | |
errors: subErrors, | |
touched: subTouched, | |
status: parentFormik.status, | |
}; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment