Will King
Remix
How-to

How to code forms with list fields in Remix

A list input allows you to add new entries to singular resource. If you come from a Wordpress background you may be familiar with the term repeater field.

So, how do you go about adding one? The guide below is based on the architecture used in Remix, but most of the core solution can be easily adapted to whatever framework you're using.

Let's start with breaking down where most of the complexity lives.

The UI Layer

The UI layer has some interesting requirements to keep in mind when we are implementing a list field.

  • Add and remove items in list

  • Can handle existing entries during initialization

  • Need to keep order in UI and on server

  • Need to name inputs in a way that the server reads fields as an array of values vs individual fields

Step One: Build just the add/remove functionality

Let's build some UI that will show an array of fields and allow us to add and remove fields from that list. I prefer to use uncontrolled inputs unless it can be avoided. Since we are not tracking values we will need something to build our state around, and when you are mapping over and array to render in React you need a key sooooo why don't we just do an array of keys.

import { XMarkIcon } from "@heroicons/react/24/solid";

function randomId() {
  return Math.random()
    .toString(36)
    .replace(/[^a-z]+/g, "");
}

function ListField() {
  const [list, setList] = useState([randomId()]);

  function add() {
    setList((prev) => [...prev, randomId()]);
  }

  function remove(uuid: string) {
    setList((prev) => prev.filter((id) => id !== uuid));
  }

  const showRemove = list.length > 1;

  return (
    <div>
      <div>
        {list.map((uuid, order) => (
          <fieldset key={uuid}>
            <label>
              <span className="sr-only">Resource Name</span>
              <input type="text" required />
            </label>
            <label>
              <span className="sr-only">Resource Link</span>
              <input type="text" required />
            </label>
            {showRemove ? (
              <button onClick={() => remove(uuid)}>
                <XMarkIcon className="h-5 w-5" />
              </button>
            ) : null}
          </fieldset>
        ))}
      </div>
      <div>
        <button onClick={add}>Add another</button>
      </div>
    </div>
  );
}

Step Two: Get the name right

Now that we have the ability to add and remove fields let's make sure that we are naming the inputs The right way for the server to be able to group these fields as a single array over there too.

For this example we are doing to represent a list of url resources. We will be using a package named q-set that can parse string based object paths and help us turn:

"resources[0][link]": "https://some.url"

Into:

{ 
  resources: [ 
    { 
      url: "https://some.url"
    }
  ]
}
{list.map((uuid, order) => (
  <fieldset key={uuid}>
    <label>
      <span className="sr-only">Resource Name</span>
      <input name={`resources[${order}][name]`} type="text" required />
    </label>
    <label>
      <span className="sr-only">Resource Link</span>
      <input name={`resources[${order}][link]`} type="text" required />
    </label>
  </fieldset>
)}

Step Three: Support editing existing list

So far we have been hardcoding an empty array to initialize our state, but in the real world you will also need to account for when we are editing existing items. Let's take a look at what we need to do to handle that.

  1. Instead of our state being initialized with a random string key, we will use a list of all the ids for our existing resources.

  2. As we map over the keys we will need to find the current item from the existing resources.

  3. If a resource is found, populate the default field value with resource url. This gives us a starting point, but allows us to change the value as needed.

  4. If a resource is found, add a hidden field for id so server knows if we are updating an existing resource or creating a new one.

function ListField({ resources }: { resources?: Resource[] }) {
  const [list, setList] = useState(
    resources && resources.length > 0
      ? resources.map(({ id }) => id)
      : [randomId()]
  );

  function add() {
    setList((prev) => [...prev, randomId()]);
  }

  function remove(key: string) {
    setList((prev) => prev.filter((k) => k !== key));
  }

  const showRemove = list.length > 1;

  return (
    <div>
      <div>
        {list.map((uuid, order) => {
          const current = resources.find(({ id }) => id === uuid);
          return (
            <fieldset key={uuid}>
              {current ? (
                <input
                  type="hidden"
                  name={`resources[${order}][id]`}
                  value={uuid}
                />
              ) : null}
              <label>
                <span className="sr-only">Resource Name</span>
                <input
                  type="text"
                  name={`resources[${order}][name]`}
                  defaultValue={current?.name}
                  required
                />
              </label>
              <label>
                <span className="sr-only">Resource Link</span>
                <input
                  type="text"
                  name={`resources[${order}][link]`}
                  defaultValue={current?.link}
                  required
                />
              </label>
              {showRemove ? (
                <button onClick={() => remove(uuid)}>
                  <XIcon className="h-5 w-5" />
                </button>
              ) : null}
            </fieldset>
          );
        })}
      </div>
      <div>
        <button onClick={add}>Add another</button>
      </div>
    </div>
  );
}

Okay that does it on functionality. If you want to take this and run with it you absolutely can. Below I am going to dive into taking it the last 20% and clean up the implementation and add in styles/components that I left out up to this point for clarity sake, but would be there in a production app.

  • Pull out the state and helper functions into a custom hook

  • Add in layout and styles with Tailwind

  • Substitute raw <input/> and <button/> elements with components that have the apps design system built into them.

  • You can checkout this article that dives specifically into the <Button/> component I'm using here.

function randomId() {
	return Math.random()
		.toString(36)
		.replace(/[^a-z]+/g, '')
}

export function useListField<T extends { id: string }>(
  items?: T[]
): { list: string[]; add(): void; remove?: (key: string) => void } {
  const [list, setList] = useState(
    items && items.length > 0 ? items.map(({ id }) => id) : [randomKey()]
  );

  function add() {
    setList((prev) => [...prev, randomKey()]);
  }

  function remove(key: string) {
    setList((prev) => prev.filter((k) => k !== key));
  }

  const showRemove = list.length > 1;

  return { list, add, remove: showRemove ? remove : undefined };
}

function ListField({ resources }: { resources?: Resource[] }) {
  const { list, add, remove } = useListField(resources);

  return (
    <div>
      <div className="grid gap-6">
        {list.map((uuid, order) => {
          const item = resources?.find(({ id }) => id === uuid);
          return (
            <fieldset
              key={key}
              className="flex flex-col gap-x-6 gap-y-2 md:flex-row"
            >
              {current ? (
                <input
                  type="hidden"
                  name={`resources[${order}][id]`}
                  value={key}
                />
              ) : null}
              <label className="flex-1">
                <span className="sr-only">Resource Name</span>
                <Input
                  type="text"
                  name={`resources[${order}][name]`}
                  defaultValue={current?.name}
                  className="w-full"
                  required
                />
              </label>
              <label className="flex-1">
                <span className="sr-only">Resource Link</span>
                <Input
                  type="text"
                  name={`resources[${order}][link]`}
                  defaultValue={current?.link}
                  className="w-full"
                  required
                />
              </label>
              {remove ? (
                <Button onClick={() => remove(key)}>
                  <XIcon className="h-5 w-5" />
                </Button>
              ) : null}
            </fieldset>
          );
        })}
      </div>
      <div className="pt-2">
        <Button variant="outline" onClick={add}>
          Add another
        </Button>
      </div>
    </div>
  );
}

The Server Layer

Now that we have our UI what happens when we submit the form that includes our list field?

There is really only one thing to worry about on the server.

The normal process of accessing values from submitted forms uses the FormData object.

However, this only works when you are doing single level string fields. When working with array values your data comes out looking like this. (Using field names used in the UI section above)

{ 
  "resources[0][id]": "randomIdHere",
  "resources[0][url]": "https://some.url",
}

How are we going to turn that into the nested data structure we are expecting?

  1. Get formData from request

  2. Convert formData object into a regular object

  3. This is where the magic is…Run the object through q-set's deep function.

function fieldNamesToObject(
	formData: FormData,
): Record<string, string | Record<string, string | number>[]> {
	return Object.entries(Object.fromEntries(formData)).reduce(
		(acc, [key, value]) => deep(acc, key, value),
		{},
	)
}

export async function loader({ request }: Request) { 
  // 1 - Get form data from request
  const formData = await request.formData()
  // 2 - Turn FormData object into regular object
  const formObject = Object.fromEntries(formData)
  // 3 - Loop over entries in object and merge them into 
  //     parsed object using q-set's deep function
  const parsedForm = Object.entries(formObject).reduce((acc, [key, value]) => deep(acc, key, value), {})
  // ... do whatever you want
}

Appendix

If you've made it this far welcome to the appendix. This is where we take the core functionality and do a little more. Partly for fun, but also in case you need more than just the basics too. I have created a working example for the following in a CodeSandbox for you to explore

Link to CodeSandbox

Reusable <ListField />

In the component we created in the tutorial above the list data was hard coded into the implementation. What if I need a list of resources and also a list of ingredients?

  1. Make the data type passed into the ListField generic. This allows us to handle lists with different shapes of data.

  2. Replace hard-coded inputs with a function as child pattern to pass through context.

  3. Make add button text editable

Adding in Drag-n-Drop reordering

There are so many libraries out there for this functionality so feel free to use what you're comfortable with, but for this example we will be using react-beautiful-dnd.

Updates and More

Get updated when new articles, products, or components are released. Also, whatever else I feel like would be fun to send out.