Skip to content

Instantly share code, notes, and snippets.

@samselikoff
Created July 26, 2022 19:04
Show Gist options
  • Save samselikoff/510c020e4c9ec17f1cf76189ce683fa8 to your computer and use it in GitHub Desktop.
Save samselikoff/510c020e4c9ec17f1cf76189ce683fa8 to your computer and use it in GitHub Desktop.

Remix's useFetcher doesn't return a Promise for any of its methods (like fetcher.submit()) because Remix doesn't want you to explicitly await anything so they can handle things like cancellation for you. Instead, they recommend adding a useEffect and performing whatever logic you need to after the fetcher is in a particular state.

I found using an effect to run some logic after a submission to be too indirect, and there seem to be plenty of cases where you want to submit a form and then perform some other work on the client (sometimes async, like requesting the user's permission for their location), and I'd rather just do that after a submission in the event handler rather than an effect.

So here's a proof of concept hook that wraps Remix's useFetcher and returns a version of submit that is a promise, and resolves with the data from the action:

function useFetcherWithPromise() {
  let resolveRef = useRef();
  let promiseRef = useRef();
  if (!promiseRef.current) {
    promiseRef.current = new Promise((resolve) => {
      resolveRef.current = resolve;
    });
  }
  let fetcher = useFetcher();

  async function submit(...args) {
    fetcher.submit(...args);
    return promiseRef.current;
  }

  useEffect(() => {
    if (fetcher.data) {
      resolveRef.current(fetcher.data);
    }
  }, [fetcher]);

  return { ...fetcher, submit };
}

Now I can use it like this:

<fetcher.Form
  onSubmit={handleSubmit}
  className="mt-4 space-y-4"
  method="post"
>
  <!--  form  -->
</fetcher.Form>

and the event handler:

async function handleSubmit(event) {
  event.preventDefault();

  let data = await fetcher.submit(event.target, { method: "post" });
  // do additional work
  
  return navigate(`/exercises/${data.exerciseId}`);
}

Know that this subjects you to some pitfalls that Remix's Form is designed to protect you from, but again it seems to me there are plenty of cases where you'd want to drop down to this level.

@bakikucukcakiroglu
Copy link

Thanks all, very nice job! It is always amazing to see developers who also encountered your problem years ago.

@paul-vd
Copy link

paul-vd commented Aug 28, 2024

Added a little cleanup and generic types to allow to infer from the action!

import type { SerializeFrom } from '@remix-run/node'
import { useFetcher } from '@remix-run/react'
import type { AppData } from '@remix-run/react/dist/data'
import React from 'react'

type FetcherData<T> = NonNullable<SerializeFrom<T>>
type ResolveFunction<T> = (value: FetcherData<T>) => void

export function useFetcherWithPromise<TData = AppData>(opts?: Parameters<typeof useFetcher>[0]) {
  const fetcher = useFetcher<TData>(opts)
  const resolveRef = React.useRef<ResolveFunction<TData>>()
  const promiseRef = React.useRef<Promise<FetcherData<TData>>>()

  if (!promiseRef.current) {
    promiseRef.current = new Promise<FetcherData<TData>>((resolve) => {
      resolveRef.current = resolve
    })
  }

  const resetResolver = React.useCallback(() => {
    promiseRef.current = new Promise((resolve) => {
      resolveRef.current = resolve
    })
  }, [promiseRef, resolveRef])

  const submit = React.useCallback(
    async (...args: Parameters<typeof fetcher.submit>) => {
      fetcher.submit(...args)
      return promiseRef.current
    },
    [fetcher, promiseRef]
  )

  React.useEffect(() => {
    if (fetcher.state === 'idle') {
      if (fetcher.data) {
        resolveRef.current?.(fetcher.data)
      }
      resetResolver()
    }
  }, [fetcher, resetResolver])

  return { ...fetcher, submit }
}

can't wait for https://github.com/orgs/remix-run/projects/5?pane=issue&itemId=62177552 to drop!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment