@matthew.ngo/reform
Version:
A flexible and powerful React form management library with advanced validation, state observation, and multi-group support
307 lines (268 loc) • 7.89 kB
text/typescript
import { UseFormReturn, Path } from "react-hook-form";
import { useCallback, useRef, useEffect, useMemo } from "react";
/**
* Import the FormGroup type from base types
*/
import { useMemoizedCallback } from "../../common/useMemoizedCallback";
import { FieldArrayHelpers } from "./types";
import { FormGroup } from '../../core/form/form-groups';
/**
* Props for the useFieldArrayHelpers hook
*
* @template T - The type of form data within each group
*/
interface UseFieldArrayHelpersProps<T> {
/** React Hook Form methods for the form */
methods: UseFormReturn<{ groups: FormGroup<T>[] }>;
/** Optional callback triggered when array fields are modified */
onChange?: (groups: FormGroup<T>[]) => void;
}
/**
* Hook that provides utilities for managing array fields within form groups
*
* This hook enables manipulation of array fields inside Reform groups, providing
* methods similar to React Hook Form's useFieldArray but adapted for the Reform
* group structure.
*
* @template T - The type of form data within each group
*/
export const useFieldArrayHelpers = <T extends Record<string, any>>({
methods,
onChange,
}: UseFieldArrayHelpersProps<T>): FieldArrayHelpers<T> => {
// Store methods reference to avoid unnecessary re-renders
const methodsRef = useRef(methods);
useEffect(() => {
methodsRef.current = methods;
}, [methods]);
// Store onChange reference to avoid unnecessary re-renders
const onChangeRef = useRef(onChange);
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
/**
* Creates a properly typed path for accessing array fields
*
* @param index - The group index
* @param field - The field name containing the array
* @returns A type-safe path string for React Hook Form
*/
const createArrayPath = useMemoizedCallback(
<K extends keyof T>(
index: number,
field: K
): Path<{ groups: FormGroup<T>[] }> => {
return `groups.${index}.data.${String(field)}` as Path<{
groups: FormGroup<T>[];
}>;
},
[]
);
/**
* Gets the array value for a specific field
*/
const getArray = useMemoizedCallback(
<K extends keyof T>(index: number, field: K): any[] => {
const path = createArrayPath(index, field);
const value = methodsRef.current.getValues(path);
return Array.isArray(value) ? value : [];
},
[createArrayPath]
);
/**
* Notifies about changes if onChange callback is provided
*/
const notifyChange = useMemoizedCallback(() => {
if (onChangeRef.current) {
onChangeRef.current(methodsRef.current.getValues().groups);
}
}, []);
/**
* Appends a new value to the end of an array field
*/
const append = useMemoizedCallback(
<K extends keyof T>(
index: number,
field: K,
value: any,
options?: { shouldFocus?: boolean }
) => {
const path = createArrayPath(index, field);
const currentArray = getArray(index, field);
methodsRef.current.setValue(path, [...currentArray, value], {
shouldValidate: true,
shouldDirty: true,
shouldTouch: true,
});
notifyChange();
},
[createArrayPath, getArray, notifyChange]
);
/**
* Removes an item from an array field at the specified index
*/
const remove = useMemoizedCallback(
<K extends keyof T>(index: number, field: K, arrayIndex: number) => {
const path = createArrayPath(index, field);
const currentArray = getArray(index, field);
if (arrayIndex < 0 || arrayIndex >= currentArray.length) {
return;
}
const newArray = [
...currentArray.slice(0, arrayIndex),
...currentArray.slice(arrayIndex + 1),
];
methodsRef.current.setValue(path, newArray, {
shouldValidate: true,
shouldDirty: true,
shouldTouch: true,
});
notifyChange();
},
[createArrayPath, getArray, notifyChange]
);
/**
* Updates an item in an array field at the specified index
*/
const update = useMemoizedCallback(
<K extends keyof T>(
index: number,
field: K,
arrayIndex: number,
value: any
) => {
const path = createArrayPath(index, field);
const currentArray = getArray(index, field);
if (arrayIndex < 0 || arrayIndex >= currentArray.length) {
return;
}
const newArray = [...currentArray];
newArray[arrayIndex] = value;
methodsRef.current.setValue(path, newArray, {
shouldValidate: true,
shouldDirty: true,
shouldTouch: true,
});
notifyChange();
},
[createArrayPath, getArray, notifyChange]
);
/**
* Moves an item from one position to another within an array field
*/
const move = useMemoizedCallback(
<K extends keyof T>(index: number, field: K, from: number, to: number) => {
const path = createArrayPath(index, field);
const currentArray = getArray(index, field);
if (
from < 0 ||
from >= currentArray.length ||
to < 0 ||
to >= currentArray.length
) {
return;
}
const newArray = [...currentArray];
const [movedItem] = newArray.splice(from, 1);
newArray.splice(to, 0, movedItem);
methodsRef.current.setValue(path, newArray, {
shouldValidate: true,
shouldDirty: true,
shouldTouch: true,
});
notifyChange();
},
[createArrayPath, getArray, notifyChange]
);
/**
* Swaps two items in an array field
*/
const swap = useMemoizedCallback(
<K extends keyof T>(
index: number,
field: K,
indexA: number,
indexB: number
) => {
const path = createArrayPath(index, field);
const currentArray = getArray(index, field);
if (
indexA < 0 ||
indexA >= currentArray.length ||
indexB < 0 ||
indexB >= currentArray.length
) {
return;
}
const newArray = [...currentArray];
const temp = newArray[indexA];
newArray[indexA] = newArray[indexB];
newArray[indexB] = temp;
methodsRef.current.setValue(path, newArray, {
shouldValidate: true,
shouldDirty: true,
shouldTouch: true,
});
notifyChange();
},
[createArrayPath, getArray, notifyChange]
);
/**
* Inserts a new value at a specific position in an array field
*/
const insert = useMemoizedCallback(
<K extends keyof T>(
index: number,
field: K,
arrayIndex: number,
value: any
) => {
const path = createArrayPath(index, field);
const currentArray = getArray(index, field);
if (arrayIndex < 0 || arrayIndex > currentArray.length) {
return;
}
const newArray = [
...currentArray.slice(0, arrayIndex),
value,
...currentArray.slice(arrayIndex),
];
methodsRef.current.setValue(path, newArray, {
shouldValidate: true,
shouldDirty: true,
shouldTouch: true,
});
notifyChange();
},
[createArrayPath, getArray, notifyChange]
);
/**
* Replaces the entire array with a new array
*/
const replace = useMemoizedCallback(
<K extends keyof T>(index: number, field: K, newArray: any[]) => {
const path = createArrayPath(index, field);
methodsRef.current.setValue(path, newArray, {
shouldValidate: true,
shouldDirty: true,
shouldTouch: true,
});
notifyChange();
},
[createArrayPath, notifyChange]
);
// Memoize the return object to prevent unnecessary re-renders
return useMemo(
() => ({
getArray,
append,
remove,
update,
move,
swap,
insert,
replace,
}),
[getArray, append, remove, update, move, swap, insert, replace]
);
};