react-router icon indicating copy to clipboard operation
react-router copied to clipboard

[Feature]: GraphQL Mutations in Actions

Open connelhooley opened this issue 2 years ago • 6 comments

What is the new or updated feature that you are suggesting?

I would like to submit a request via GraphQL when I submit a form. I am using react-hook-form to validate and format the object I want to submit. I know the docs recommend using browser validation but I would prefer to keep react-hook-form if possible. There's an awful lot of stuff it handles for me. E.g. handling repeatable inputs, formatting all the fields into a structured object.

If I could pass an object into an action from my component via the useSubmit hook, this would unlock a lot more scenarios for react-router imo.

E.g. something like this:

import { API, graphqlOperation } from "aws-amplify";
import { useForm } from "react-hook-form";
import { useSubmit } from "react-router-dom";

import { createUser } from './graphql/mutations';

const CreateUser = () => {
  const submit = useSubmit(); // react-router-dom
  const { register, handleSubmit } = useForm(); // react-hook-form
  const onSubmit = data => {
    submit(data);
  };
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("user.name", { required: true })} />
      <button type="submit">Save</button>
    </form>
  );
};

export default CreateUser;

export const action = async ({ request }) => {
  console.log(request.user.name); // Request is the first object passed to submit();
  await API.graphql(graphqlOperation(createUser, { input: request }));
};

Is there a better way to do this without such a feature? I can't see an obvious and nice way to invoke a graphql mutation without ditching react-form-hooks. This would mean I:

  • Would need to build code to have repeatable inputs (adding a new item, removing an item, re-ordering etc)
  • When the form is submitted to the action, I need to take the form data and map it into a JS object that matches the GraphQL mutation schema.
  • Rollout my own validation code, using the browser validation APIs. I believe out of the box it shows errors straight away, whereas I would want to wait until the form is submitted once before showing errors. It is this kind of logic I don't want to rollout myself.

Maybe I shouldn't be using actions but my "OCD" is telling me that if I'm using loaders (which work great with GraphQL, there's docs for it), I should be using actions!

Why should this feature be included?

  • Makes react-router less opinionated. Feels like the current design is really imposing on how developers should build forms, I don't think developers should have to leave their favoured form packages to use actions.
  • Helps support graphql mutations which isn't done over form encoding.

connelhooley avatar Sep 28 '22 23:09 connelhooley

You can submit a plain object (non-nested) and it will be serialized into the request form data:

let submit = useSubmit();
submit({ key: 'value' });
async function action({ request }) {
  let fd = await request.formData()
  // fd.get('key') === 'value'
}

Does that handle your use case?

brophdawg11 avatar Sep 29 '22 01:09 brophdawg11

Hi, thanks for replying and apolgies for getting back to you so late.

I didn't know about that feature so it's good to know as it helps a little. Knowing that I could do the following:

import { useForm } from "react-hook-form";
import { useSubmit } from "react-router-dom";
import { set } from "lodash";

export default function App() {
  const { register, handleSubmit, formState: { errors } } = useForm();
  const submit = useSubmit();
  const onSubmit = data => submit(data);
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("name.first")} />
      <input {...register("name.last", { required: true })} />
      {errors.name.last && <span>This field is required</span>} 
      <input type="submit" />
    </form>
  );
};

export const action = async ({ request }) => {
  const user = request.formData().entries().reduce((acc, [key, val]) => set(acc, key, val), {});
  await API.graphql(graphqlOperation(createUser, { input: user }));
};

The action will accept the key/value formdata object and re-parse it back into a structured object. This approach has some flaws though, e.g. there's a difference in how react-form-hook handles arrays and how lodash set does: Lodash users[0].firstname vs form hooks users.0.firstname.

I think parsing the results in and out of formdata is not a nice approach. If it was possible to send a js object directly to the action that would make the scenario of using a graphql API and a form library much better.

connelhooley avatar Oct 07 '22 21:10 connelhooley

We're pretty happy leveraging standardized web APIs such as FormData, which is great for key/value pairs, including multiple values per key. If your object is complex such that a simple object with key/value pairs won't do the trick, then you can do any type of custom serialization you need and just send the string in a single-key object and then parse it in the action:

import { useForm } from "react-hook-form";
import { useSubmit } from "react-router-dom";
import { set } from "lodash";

export default function App() {
  const { register, handleSubmit, formState: { errors } } = useForm();
  const submit = useSubmit();
  const onSubmit = data => submit({ 
    // You can implement any custom serialization logic here
    serialized: JSON.stringify(data)
  });
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("name.first")} />
      <input {...register("name.last", { required: true })} />
      {errors.name.last && <span>This field is required</span>} 
      <input type="submit" />
    </form>
  );
};

export const action = async ({ request }) => {
  let formData = await request.formData();
  // And then just parse your own format here
  let user = JSON.parse(formData.get('serialized'));
  await API.graphql(graphqlOperation(createUser, { input: user }));
};

brophdawg11 avatar Oct 11 '22 18:10 brophdawg11

@brophdawg11 Your solution is only a workaround as serialization is an unnecessary step if we can transfer a reference to an object.

It would be better to add the customData parameter to the SubmitFunction

export interface SubmitFunction {
    (
    /**
     * Specifies the `<form>` to be submitted to the server, a specific
     * `<button>` or `<input type="submit">` to use to submit the form, or some
     * arbitrary data to submit.
     *
     * Note: When using a `<button>` its `name` and `value` will also be
     * included in the form data that is submitted.
     */
    target: SubmitTarget, 
    /**
     * Options that override the `<form>`'s own attributes. Required when
     * submitting arbitrary data without a backing `<form>`.
     */
    options?: SubmitOptions

    /**
      * User data
      */
    customData?: any): void;
}

and next pass customData as the third parameter to an action:

export interface ActionFunctionArgs extends DataFunctionArgs {
 customData?: any;
}

Like @connelhooley I use graphql mutations (Relay), but my form outputs are typed objects obtained from Formik.

The new react-router version with loaders is fantastic, and it's great step forward as we all need render-as-you-fetch. However, the library needs some additional features to be truly useful.

supersnager avatar Oct 19 '22 15:10 supersnager

We're pretty happy leveraging standardized web APIs such as FormData, which is great for key/value pairs, including multiple values per key. If your object is complex such that a simple object with key/value pairs won't do the trick, then you can do any type of custom serialization you need and just send the string in a single-key object and then parse it in the action:

import { useForm } from "react-hook-form";
import { useSubmit } from "react-router-dom";
import { set } from "lodash";

export default function App() {
  const { register, handleSubmit, formState: { errors } } = useForm();
  const submit = useSubmit();
  const onSubmit = data => submit({ 
    // You can implement any custom serialization logic here
    serialized: JSON.stringify(data)
  });
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("name.first")} />
      <input {...register("name.last", { required: true })} />
      {errors.name.last && <span>This field is required</span>} 
      <input type="submit" />
    </form>
  );
};

export const action = async ({ request }) => {
  let formData = await request.formData();
  // And then just parse your own format here
  let user = JSON.parse(formData.get('serialized'));
  await API.graphql(graphqlOperation(createUser, { input: user }));
};

Could someone please tell why doesn't the example above work for me? Here's it is in a sandbox. https://codesandbox.io/s/nice-pike-zy3hqc?file=/src/index.js

I am content if I can just console.log the value inside the action. I can take it from there.

Thank you.

onattech avatar Dec 04 '22 13:12 onattech

I figure it out. I had to use the fetcher to pass the data to action.

import { useForm } from "react-hook-form";
import { useFetcher } from "react-router-dom";

export default function App() {
  const fetcher = useFetcher();
  const {
    register,
    handleSubmit,
    formState: { errors }
  } = useForm();
  const onSubmit = (data) =>
    fetcher.submit(
      {
        // You can implement any custom serialization logic here
        serialized: JSON.stringify(data)
      },
      { method: "post", action: "/" }
    );

  return (
    <fetcher.Form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("first")} />
      <input {...register("last", { required: true })} />
      {errors?.name?.last && <span>This field is required</span>}
      <input type="submit" />
    </fetcher.Form>
  );
}

export const action = async ({ request }) => {
  console.log("action called....");
  let formData = await request.formData();
  // And then just parse your own format here
  let user = JSON.parse(formData.get("serialized"));
  console.log(user); // <== I am content if this console.log works
  return null;
};

onattech avatar Dec 06 '22 19:12 onattech

I'm going to convert this to a discussion so it can go through our new Open Development process. Please upvote the new Proposal if you'd like to see this considered!

brophdawg11 avatar Jan 09 '23 22:01 brophdawg11