How to handle multiple concurrent submissions in Remix

Tiger AbrodiTiger Abrodi
7 min read

Introduction

Preface: Remix v3 is the same as React Router 7 (as a framework). I still name it Remix in blog post titles because that's what people are used to at the moment (I need to stop though lol).

In my last side project, I built a note taking app inspired by Apple Notes.

I built it with Convex, but I've been thinking a lot about how to build a pending UI experience in React Router.

I wanna note down some thoughts after playing with React Router and needing to not just submit with a fetcher, but creating multiple fetchers (a single useFetcher wouldn’t do the job here 😅).

As you can see in the image, we have a button to add a note.

My idea is that you can rapidly click on the button. Every time you click, you'll see a pending note item. This item is disabled and in pending state (not clickable).

We can't do it fully optimistically here because I'm using nested routing. So the note editor part is actually it's own <Outlet />. The full route is /notebook/:folderId/:noteId.

/notebook renders the sidebar with folders.

/notebook/:folderId renders the note items in that folder. This is where we'll see the pending note items. This part also renders the header section.

/notebook/:folderId/:noteId renders the note editor.

My idea is that you can rapidly click on the add button multiple times, when all fetchers are done, you get redirected to the last note item you created. This redirect can be a bit tricky. We'll deal with it in the end.

Rendering the note items

I'm very used to React Router at this point, so I'm just gonna be writing code in here (I'm literally writing this blog post in raw markdown and then formatting it on hashnode lol).

How we wanna render the note items would be getting the loader data from the route props:

export const loader = /* ... */

export default function NoteItems({ loaderData }: Route.ComponentProps) {
  return (
    <FolderBar>
     <Header>
      <h1>Notes</h1>
       <button>
        Add note
       </button>
     </Header>

    {loaderData.notes.map((note) => (
      <NoteItem key={note.id} note={note} />
    ))}
    </FolderBar>
  )
}

This isn't exactly how the real code is. But let's focus on the React Router logic.

useFetcher and its problems

Let's try with a fetcher. It'd roughly look something like this:

export default function NoteItems({ loaderData }: Route.ComponentProps) {
  const fetcher = useFetcher();
  const fetchers = useFetchers();

  const originalNotes = loaderData.notes;
  const optimisticNotes = [];

  for (const fetcher of fetchers) {
    if (fetcher.formData) {
      const obj = Object.fromEntries(fetcher.formData);
      optimisticNotes.push({
        ...obj,
        id: randomNumber(),
        isPending: true,
      });
    }
  }

  const notes = [...originalNotes, ...optimisticNotes];

  return (
    <FolderBar>
      <Header>
        <h1>Notes</h1>
        <form
          onSubmit={(event) => {
            event.preventDefault();
            const formData = new FormData();
            formData.set("title", "New note");
            formData.set("content", "Some default content");
            fetcher.submit(formData, {
              method: "post",
            });
          }}
        >
          <button type="submit">Add note</button>
        </form>
      </Header>

      {notes.map((note) => (
        <NoteItem
          key={note.id}
          note={note}
          isPending={note.isPending}
        />
      ))}
    </FolderBar>
  );
}

Of course, if multiple different submissions could happen, you'll want to have "intent" on the form data so you can identify which fetcher does what.

The problem with this approach is that we'll only see ONE pending UI. If you click add 4 times rapidly, you won't see 4 pending UI. You'll only see 1.

The reason for this is because the fetcher is a "single" instance tied to the component. Each fetcher maintains one state and one optimistic update at a time. When you make multiple submissions through the same fetcher instance, each new submission overwrites the previous optimistic state, rather than maintaining separate states for concurrent submissions.

This limitation exists because fetchers are designed for component-scoped mutations, where each fetcher instance handles one operation at a time.

“Can adding a key option help here somehow?”

A key’s job is just to help you identify which fetcher is which e.g. when calling useFetchers in another part of the app.

useSubmit to the rescue

useSubmit gives us a submit function. What we could do instead is to use that submit function with the navigate option set to false. When the navigate option is false, it's gonna create a new fetcher every time you submit.

export default function NoteItems({ loaderData }: Route.ComponentProps) {
  const submit = useSubmit();
  const fetchers = useFetchers();

  const originalNotes = loaderData.notes;
  const optimisticNotes = [];

  for (const fetcher of fetchers) {
    if (fetcher.formData) {
      const obj = Object.fromEntries(fetcher.formData);
      optimisticNotes.push({
        ...obj,
        isPending: true,
      });
    }
  }

  const notes = [...originalNotes, ...optimisticNotes];

  return (
    <FolderBar>
      <Header>
        <h1>Notes</h1>
        <form
          onSubmit={(event) => {
            event.preventDefault();
            const formData = new FormData();
            formData.set("title", "New note");
            formData.set("content", "Some default content");
            submit(formData, {
              method: "post",
              navigate: false,
            });
          }}
        >
          <button type="submit">Add note</button>
        </form>
      </Header>

      {notes.map((note) => (
        <NoteItem key={note.title} note={note} isPending={note.isPending} />
      ))}
    </FolderBar>
  );
}

This looks promising at first glance. But there's a subtle problem when you add multiple notes quickly.

Let's say you click "Add note" twice. Here's what happens:

  1. When the first note is saved successfully, React Router automatically revalidates the data

  2. This revalidation loads ALL notes from the database

  3. But our second note is still showing as a pending optimistic update

  4. Result: we see the second note twice - once from the database and once from our optimistic UI

This happens because React Router revalidates after each successful action, but our optimistic updates are still being tracked by their fetchers. We need a way to know which notes are "real" and which are still pending.

We have a "race condition" here.

Client-side IDs to the rescue

Here's how we fix it:

export default function NoteItems({ loaderData }: Route.ComponentProps) {
  const submit = useSubmit();
  const fetchers = useFetchers();

  const originalNotes = loaderData.notes;
  const existingIds = new Set(originalNotes.map((note) => note.id));
  const optimisticNotes = [];

  for (const fetcher of fetchers) {
    if (fetcher.formData) {
      const obj = Object.fromEntries(fetcher.formData);
      if (!existingIds.has(obj.id)) {
        optimisticNotes.push({
          ...obj,
          isPending: true,
        });
      }
    }
  }

  const notes = [...originalNotes, ...optimisticNotes];

  return (
    <FolderBar>
      <Header>
        <h1>Notes</h1>
        <form
          onSubmit={(event) => {
            event.preventDefault();
            const formData = new FormData();
            // ID sent along to the server too
            const id = crypto.randomUUID();
            formData.set("id", id);
            formData.set("title", "New note");
            formData.set("content", "Some default content");
            submit(formData, {
              method: "post",
              navigate: false,
            });
          }}
        >
          <button type="submit">Add note</button>
        </form>
      </Header>

      {notes.map((note) => (
        <NoteItem key={note.id} note={note} isPending={note.isPending} />
      ))}
    </FolderBar>
  );
}

The key changes are:

Generate a client-side ID when submitting. Track existing IDs from our loader data. Only add optimistic notes if their ID isn't in our database yet.

Now when a note is saved, its ID will match between the database and our optimistic UI, preventing duplicates during revalidation. Race condition solved!

Redirecting to the last created note

Finally, we want to redirect to the last created note once all submissions are complete.

Here's the full code:

export default function NoteItems({ loaderData }: Route.ComponentProps) {
  const submit = useSubmit();
  const navigate = useNavigate();
  const fetchers = useFetchers();
  const { folderId } = useParams();
  const [lastNoteId, setLastNoteId] = (useState < string) | (null > null);

  const originalNotes = loaderData.notes;
  const existingIds = new Set(originalNotes.map((note) => note.id));
  const optimisticNotes = [];

  for (const fetcher of fetchers) {
    if (fetcher.formData) {
      const obj = Object.fromEntries(fetcher.formData);
      if (!existingIds.has(obj.id)) {
        optimisticNotes.push({
          ...obj,
          isPending: true,
        });
      }
    }
  }

  const notes = [...originalNotes, ...optimisticNotes];

  useEffect(() => {
    if (fetchers.length === 0 && lastNoteId) {
      navigate(`/notebook/${folderId}/${lastNoteId}`);
      setLastNoteId(null);
    }
  }, [fetchers.length, lastNoteId, folderId, navigate]);

  return (
    <FolderBar>
      <Header>
        <h1>Notes</h1>
        <form
          onSubmit={(event) => {
            event.preventDefault();
            const formData = new FormData();
            const id = crypto.randomUUID();
            formData.set("id", id);
            formData.set("title", "New note");
            formData.set("content", "Some default content");
            // Every time we submit, we set the last note id to the id of the note we're submitting
            setLastNoteId(id);
            submit(formData, {
              method: "post",
              navigate: false,
            });
          }}
        >
          <button type="submit">Add note</button>
        </form>
      </Header>

      {notes.map((note) => (
        <NoteItem key={note.id} note={note} isPending={note.isPending} />
      ))}
    </FolderBar>
  );
}

We track the last submitted note's ID with useState. When all fetchers complete (fetchers.length === 0), we navigate to that note and reset the state.

Conclusion

I'm not entirely sure if all code would work. But from my understanding, this should work.

I tend to think too much about code lol

It's just when I was building it with Convex, I always felt like it'd be much easier to do this in React Router lol

Doing things optimistically in React Router is just way too good.

2
Subscribe to my newsletter

Read articles from Tiger Abrodi directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Tiger Abrodi
Tiger Abrodi

Just a guy who loves to write code and watch anime.