Implement useOrderedFieldArray Hook for forms using React Hook Form
Tags: react, form, rhf
Published: 2021-05-14 20:16:51

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`](https://react-hook-form.com/api/usefieldarray/) 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:


```typescript

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]
  );
}


```

## Usage:

```typescript
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',
  });
```
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.