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.

@miguelespinoza
Copy link

thanks for sharing, this helped with a requirement for an asynchronous wait I required on fetcher.submit

I came across one gotcha though, where once resolved subsequent submit requests would auto-resolve, due to the first call.
In order to fix this, I tweaked the hook above, now everytime a promise it resolves, it resets the resolver for future requests.
Hope this helps!

export function useFetcherWithPromise() {
  let resolveRef = useRef<any>();
  let promiseRef = useRef<Promise<any>>();
  let fetcher = useFetcher();

  if (!promiseRef.current) {
    promiseRef.current = new Promise((resolve) => {
      resolveRef.current = resolve;
    });
  }

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

  const submit: SubmitFunction = useCallback(
    async (...args) => {
      fetcher.submit(...args);
      return promiseRef.current;
    },
    [fetcher, promiseRef],
  );

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

  return { ...fetcher, submit };
}

@samselikoff
Copy link
Author

Oh yeah nice one! I’d like to revisit this at some point and think more about the “Remix” way to do this.

@wolthers
Copy link

Here's my implementation :)

import * as React from "react";
import type { FetcherWithComponents, SubmitFunction } from "@remix-run/react";
import { useFetcher } from "@remix-run/react";
import type { SerializeFrom } from "@remix-run/server-runtime";

type PromisifiedSubmitFunction<TData = unknown> = (
	...args: Parameters<SubmitFunction>
) => Promise<TData>;

type FetcherWithPromisifiedSubmit<TData = unknown> = Omit<
	FetcherWithComponents<TData>,
	"submit"
> & { submit: PromisifiedSubmitFunction<TData> };

/**
 * useFetcher() wrapper, where the only difference is that fetcher.submit()
 * returns a promise.
 *
 * Based on https://gist.github.com/samselikoff/510c020e4c9ec17f1cf76189ce683fa8
 * but using own Deferred.
 */
export function useFetcherWithPromise<
	TData = unknown,
>(): FetcherWithPromisifiedSubmit<SerializeFrom<TData>> {
	const fetcher = useFetcher<TData>();
	const deferredRef = React.useRef<Deferred<SerializeFrom<TData>>>();

	const submit: FetcherWithPromisifiedSubmit<SerializeFrom<TData>>["submit"] = (
		...args
	) => {
		deferredRef.current = new Deferred();
		fetcher.submit(...args);
		return deferredRef.current.promise;
	};

	React.useEffect(() => {
		// See fetcher states here:
		// https://remix.run/docs/en/v1/hooks/use-fetcher#fetchertype
		if (fetcher.state === "idle" && fetcher.type === "done") {
			deferredRef.current?.resolve(fetcher.data);
			deferredRef.current = undefined;
		}
	}, [fetcher.type, fetcher.state, fetcher.data]);

	return { ...fetcher, submit };
}
/**
 * A promise wrapper that can be resolved or rejected at a later point in time
 *
 * Based on jQuery Deferred: https://api.jquery.com/category/deferred-object/
 */
export class Deferred<T = unknown> {
	private _promise: Promise<T>;

	/**
	 * Resolves the promise with given value.
	 */
	public resolve!: (value: T | PromiseLike<T>) => void;

	/**
	 * Reject the promise with given reason.
	 */
	public reject!: (reason?: unknown) => void;

	/**
	 * Get the underlying promise that can be awaited.
	 */
	public get promise(): Promise<T> {
		return this._promise;
	}

	public constructor() {
		this._promise = new Promise<T>((resolve, reject) => {
			this.resolve = resolve;
			this.reject = reject;
		});
		Object.freeze(this);
	}
}

@jjhiggz
Copy link

jjhiggz commented Feb 20, 2023

thanks for sharing, this helped with a requirement for an asynchronous wait I required on fetcher.submit

I came across one gotcha though, where once resolved subsequent submit requests would auto-resolve, due to the first call. In order to fix this, I tweaked the hook above, now everytime a promise it resolves, it resets the resolver for future requests. Hope this helps!

export function useFetcherWithPromise() {
  let resolveRef = useRef<any>();
  let promiseRef = useRef<Promise<any>>();
  let fetcher = useFetcher();

  if (!promiseRef.current) {
    promiseRef.current = new Promise((resolve) => {
      resolveRef.current = resolve;
    });
  }

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

  const submit: SubmitFunction = useCallback(
    async (...args) => {
      fetcher.submit(...args);
      return promiseRef.current;
    },
    [fetcher, promiseRef],
  );

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

  return { ...fetcher, submit };
}

This is sick, I had to modify the submit type slightly to get my editor to not be mad at me. Great work!

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

@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