DEV Community

loading...

Implement useOrderedFieldArray Hook for forms using React Hook Form

Rex
・3 min read

I have the following requirements for my invoice entity:

  1. The Invoice entity has a collection of InvoiceDetail entity.

  2. User should be able to append, remove, move up and down InvoiceDetails

  3. InvoiceDetail's order needs to be consistent because they are listed in the printout of the invoice

Other documents such as contract and purchase order would have similar requirements.

The above translate to the below technical requirements:

  1. On appending, set InvoiceDetail's foreign key InvoiceId value to its parent Invoice's id on appending.

  2. On appending, set InvoiceDetail's id. I use UUID for all my domain entities, and my backend expects the front end to generate UUID, and it doesn't generate UUID automatically.

  3. On appending, moving up and down, set and maintain the order property of InvoiceDetails automatically

  4. On removing, maintain the order of the rest of InvoiceDetails.

React Hook Form has its own useFeildArray API for handling child entity collections in one-many relationships. However, for the above requirements, I decided that I would reinvent the wheels and implement my own useOrderedFieldArray hook, both as a challenge to myself and more controls potentially If I succeed.

The useOrderdFieldArray hooks would take four inputs:

  1. formContext: UseFormReturn<any>
    The form context we get back from React Hook form's useForm hook.

  2. name: string
    The name of the child collection, for example, the Invoice entity has a property 'invoiceDetails' for its Invoice Details. The name would be this 'invoiceDetails'

  3. items: T[]
    The child collection data for initialisation aka InvoiceDetails, in the Invoice case, T would be of type InvoiceDetail.

  4. newItemFactory: (...args: any[]) => Partial<T>
    A factory function to create a new child entity. args will be passed from the returned append method to this factory.

The useOrderdFieldArray hooks would return the following methods:

  1. append: (...args: any[]) => void;
    Method to append new child, args will be passed to newItemFactory input method

  2. moveDown: (index: number) => void;
    Method to move a child one step down takes the child's index in the collection array

  3. moveUp: (index: number) => void;
    Method to move a child one step up.

  4. remove: (item: T) => void;
    Remove a child from the child collection.

  5. fields: T[];
    Similar to the fields returned by React Hook Form's useFieldArray hook, it is to be used to render form controls

  6. setFields: Dispatch<SetStateAction<T[]>>;
    fields setter form the caller to set fields if appropriate.

  7. updateFieldsFromContext: () => void;
    Method to copy data from formContext into fields. When the user copy data from a selected proforma invoice to create a new commercial invoice, this method is required to sync the child forms.

Below is the code for the hook:


import { useCallback, useEffect, useMemo, useState, Dispatch, SetStateAction } from 'react';
import { UseFormReturn } from 'react-hook-form/dist/types';
import { OrderedFieldArrayMethods } from './orderedFieldArrayMethods';

interface OrderedFieldArrayMethods<T> {
  append: (...args: any[]) => void;
  moveDown: (index: number) => void;
  moveUp: (index: number) => void;
  remove: (item: T) => void;
  updateFieldsFromContext: () => void;
  fields: T[];
  setFields: Dispatch<SetStateAction<T[]>>;
}

export function useOrderedFieldArray<T extends { id: string; order: number }>({
  name,
  items,
  formContext,
  newItemFactory,
}: {
  name: string;
  items: T[];
  formContext: UseFormReturn<any>;
  newItemFactory: (...args: any[]) => Partial<T>;
}): OrderedFieldArrayMethods<T> {

  const { unregister, setValue } = formContext;

  const [fields, setFields] = useState<T[]>(() => items.sort((a, b) => a.order - b.order));

  const append = useCallback(
    (...args: any[]) => {
      setFields((fields) => [...fields, { ...newItemFactory(...args), order: fields.length } as T]);
    },
    [newItemFactory]
  );

  const moveUp = useCallback(
    (index: number) => {
      const newFields = [...fields];
      [newFields[index], newFields[index - 1]] = [newFields[index - 1], newFields[index]];
      setFields(newFields);
    },
    [fields]
  );

  const moveDown = useCallback(
    (index: number) => {
      const newFields = [...fields];
      [newFields[index], newFields[index + 1]] = [newFields[index + 1], newFields[index]];
      setFields(newFields);
    },
    [fields]
  );

  const remove = useCallback(
    (detail: { id: string }) => {
      unregister(name);
      setFields((fields) => [...fields.filter((x) => x.id !== detail.id)]);
    },
    [name, unregister]
  );

  const updateFieldsFromContext = useCallback(() => {
    setFields(formContext.getValues(name));
  }, [formContext, name]);

  useEffect(() => {
    return () => unregister(name);
  }, [name, unregister]);

  useEffect(() => {
    for (let i = 0; i < fields.length; i++) {
      setValue(`${name}[${i}].order` as any, i);
    }
  }, [fields, name, setValue]);

  return useMemo(
    () => ({
      fields,
      setFields,
      append,
      moveDown,
      moveUp,
      remove,
      updateFieldsFromContext,
    }),
    [append, fields, moveDown, moveUp, remove, updateFieldsFromContext]
  );
}


Enter fullscreen mode Exit fullscreen mode

Usage:

const { getValues } = formContext;

const newItemFactory = useCallback(
  () => ({ id: v4(), inoviceId: getValues('id') }),
  [getValues]
);

const { fields, moveUp, moveDown, remove, append, updateFieldsFromContext } = useOrderedFieldArray({
    items,
    formContext,
    newItemFactory,
    name: 'invoiceDetails',
  });
Enter fullscreen mode Exit fullscreen mode
  1. Use Fields to render child forms.
  2. wire up helper methods to buttons.

I can confirm that the above served me well so far.

Discussion (0)