#fetcher.Form rendered with action="/"

1 messages · Page 1 of 1 (latest)

fast thunder
#

This is copied from my GitHub discussion post:

I'm very new to both React and react-router, so apologies if I'm making an obvious mistake.

I'm working on a pretty basic CRUD app that stores posts, using Mantine UI components. The page to edit a post has a form with a submit button to save changes. It does this via a fetcher that calls my API endpoint /posts/<postId>/edit:

app/routes/posts.$postId_.edit.tsx

import type { Post } from "~/api";
import type { Route } from "./+types/posts.$postId_.edit";
import { ActionIcon, Stack, TextInput } from "@mantine/core";
import { useForm } from "@mantine/form";
import { useFetcher } from "react-router";

export async function clientAction({ request, params }: Route.ClientActionArgs) {
  const data = await request.formData();
  const id = params.postId;
  const res = await fetch(`/api/posts/${id}/edit`, {
    method: "POST",
    body: data,
  });
  return res;
}

export default function ShowPost({ loaderData }: Route.ComponentProps) {
  const post = loaderData;
  const fetcher = useFetcher();
  const form = useForm({
    // --- form setup ---
  });

  return (
    <fetcher.Form method="post">
      <ActionIcon type="submit">Save</ActionIcon>
      <Stack>
        <TextInput
          name="title"
          key={form.key("title")}
          {...form.getInputProps("title")}
        />
        {/* --- further form inputs --- */}
      </Stack>
    </fetcher.Form>
  );
}

This works fine. The issue is that I want to add a second button that calls the endpoint /posts/<postId>/delete to delete a post. I've tried to do this by adding a value to each button and using that to determine the action to take in clientAction:

app/routes/posts.$postId_.edit.tsx

export async function clientAction({ request, params }: Route.ClientActionArgs) {
  // --- get submitted data ---
  const intent = data.get("intent");
  if (intent === "edit") {
    return await fetch(`/api/posts/${id}/edit`, {
      method: "POST",
      body: data,
    });
  } else if (intent === "delete") {
    await fetch(`/api/posts/${id}/delete`, {
      method: "POST",
    });
    return redirect("/");
  }
}

export default function ShowPost({ loaderData }: Route.ComponentProps) {
  // --- same setup as before ---
  const deleteModal = () => {
    modals.open({
      title: "Delete post",
      children: (
        <Stack>
          <Text>Are you sure you want to delete this post?</Text>
          <fetcher.Form method="post">
            <ActionIcon
              type="submit"
              name="intent"
              value="delete"
            >
              Yes, delete it
            </ActionIcon>
          </fetcher.Form>
        </Stack>
      ),
    });
  };

  return (
    <fetcher.Form method="post">
      <ActionIcon onClick={deleteModal}>Delete</ActionIcon>
      <ActionIcon
        type="submit"
        name="intent"
        value="edit"
      >
        Save
      </ActionIcon>
      <Stack>
        <TextInput
          name="title"
          key={form.key("title")}
          {...form.getInputProps("title")}
        />
        {/* --- further form inputs --- */}
      </Stack>
    </fetcher.Form>
  );
}

However, with this approach, the HTML <form> that gets rendered inside the modal has the attribute action="/", which of course sends the request to the root route instead of the current route and causes a 405 Method Not Allowed error. The <form> element with the original edit button, however, works normally, with no action attribute set.

Of course, I'm able to specifically set the modal form with the delete button to have action={`/posts/${post.id.toString()}/edit`}, and that works fine. But this feels a bit too hacky, and I'd like to understand why this is happening in the first place.

Is there a better way to do this?

GitHub

I'm very new to both React and react-router, so apologies if I'm making an obvious mistake. I'm working on a pretty basic CRUD app that stores posts, using Mantine UI components. The pa...

#

again, i'm completely inexperienced with react. please tell me if i'm doing something stupid :^)

solar cloud
#

im not sure if im getting this right, but in fact components dont have actions or loader. so if you submit a form in a component it will always lead to the action in the route your component is rendered.

#

and you should throw redirects to stay type safety

fast thunder
#

the ShowPost component is the default component for the posts.$postId_.edit route, so it's assumed to always be accessed in that route, no? is that not typical practice?

solar cloud
#

why are you using a fetcher here and not a normal form? im just asking - cause maybe i still dont get it, im not a pro just trying to help

fast thunder
#

because my understanding is that you use a fetcher when you don't want to redirect to the path of the request you're making

#

like, i don't want to navigate to the API url

solar cloud
#

okay yea that should work and your problem is, that after adding some props to your actionbutton, the action in this route is not called anymore? it redirects to the root route?

fast thunder
#

i don't think it's about adding props, the save button still works fine, just the delete button doesn't

#

it doesn't navigate the user to the root, but it makes the request to the root that should be made to the API delete endpoint

solar cloud
#

and are you adding this const form = useForm({
// --- form setup ---
}); anywhere as a ref or something like that?

#

because in your exmaple you using fetcher.Form but it is the same form as you defined above?

fast thunder
#

that's for Mantine's form utility things, i use it in the input elements {...form.getInputProps("title")}

solar cloud
#

ok reminds me of zod 😄 but yeah are you using this form as a ref on the fetch.form? are you sure its the same form at all?

#

something like ref={form}

fast thunder
#

what does that do?

solar cloud
#

well im not sure, cause i dont see the whole code, but if you use the fetcher.Form and you define another Form outside of that scope, then you are not submitting the Form you created above no? if you use the ref={form} it assigns that reference to the actual <form> DOM node.

fast thunder
#

that shouldn't matter, the form is just for validation and setting initial values

#

i don't even know if i'm using fetchers or anything correctly, i have no clue lol

solar cloud
#

const fetcher = useFetcher<typeof clientaction>(); can you try that?

#

ofc with the ClientAction as your Clientaction 😛

fast thunder
solar cloud
#

before he ()

#

before the ()

fast thunder
#

ahhh LMAO

#

rip

#

sadly, still get a 405, the form inside the modal still has action="/"

solar cloud
#

you only got a modal while deleting it, or on creation aswell? so is the difference the modal, or you got both buttons in the modal?

fast thunder
#

there is no creation - the page is to edit a post, it has a button to save changes, then there's a button that opens a modal with a button to delete

solar cloud
#

ahh okay now im getting it

fast thunder
solar cloud
#

so kinda the confirm modal is not working at all right?

fast thunder
#

yes exactly

#

you can ignore the 'type delete to continue', it's not validated yet or sent anywhere

solar cloud
#

mhh tbh im looking at my own code atm, i implemented this with preventing the default submitting and show the modal and finally submit onConfirmed from the confirm modal but im not sure if this is the best way at all...

#

and i totally need to add the entries in the formdata again in the onconfirmed function - so maybe you are not doing it wrong at all but let me check something

fast thunder
#

i mean, i have to be doing it at least a little bit wrong or else i wouldn't be here :^)

solar cloud
#

you could do this - Set action="/some-route" explicitl

#

<fetcher.Form method="post" action="/some-route">

fast thunder
#

yes, i did that and it works, but then i'm setting the action to be the route that i'm already in and it feels super hacky

#

i thought surely that's not the way you're meant to do it, yk

#

i wanted ideally to understand why the action="/" is being set in the first place because right now it's magic to me

solar cloud
#

is your modal set in the form, or outside? because i cant see it in your example code

#

normally this happens when the component is not called inside the route hierarchy in my opinion

fast thunder
#

the modal is defined in a function, which is set as the onClick attribute of the delete button inside the form

solar cloud
#

okay i see, a form inside a modal seems to hit the wrong action, this is how i would do this, but i dont tested it 😄 sec:

#

'import { useState } from "react";
import DeleteModal from "./DeleteModal";

function DeleteButton({ itemId }) {
const [showModal, setShowModal] = useState(false);

return (
<>
<button onClick={() => setShowModal(true)}>Delete</button>

  {showModal && (
    <DeleteModal
      itemId={itemId}
      onClose={() => setShowModal(false)}
    />
  )}
</>

);
}'

#

import { useFetcher } from "react-router-dom";

function DeleteModal({ itemId, onClose }) {
const fetcher = useFetcher();

return (
<div className="modal">
<p>Are you sure you want to delete this item?</p>

  <fetcher.Form method="post">
    <input type="hidden" name="intent" value="delete" />
    <input type="hidden" name="itemId" value={itemId} />
    
    <button type="submit" disabled={fetcher.state === "submitting"}>
      {fetcher.state === "submitting" ? "Deleting..." : "Confirm Delete"}
    </button>
    <button type="button" onClick={onClose}>Cancel</button>
  </fetcher.Form>
</div>

);
}

#

export async function action({ request }) {
const formData = await request.formData();
const intent = formData.get("intent");

if (intent === "delete") {
const itemId = formData.get("itemId");
console.log("Deleting item:", itemId);
return { success: true };
}

return { error: "Invalid action" };
}

fast thunder
#

okay, lemme give it a test :)

fast thunder
#

this is really confusing. i get

Cannot update a component (`ModalsProvider`) while rendering a different component (`DeletePostModal`)
#

to be specific, this is with adapting that code to use mantine modal. otherwise i get an error that you can't include a <form> in another <form>

#

the modal is rendered outside of the rest of the page content so it doesn't have that problem

#

so:

// DeletePostButton.tsx
import { useState } from "react";
import DeletePostModal from "./DeletePostModal";

export default function DeletePostButton() {
  const [showModal, setShowModal] = useState(false);

  return (
    <>
      <button
        onClick={() => {
          setShowModal(true);
        }}
      >
        Delete
      </button>

      {showModal && <DeletePostModal />}
    </>
  );
}
// DeletePostModal.tsx
import { useFetcher } from "react-router";
import { modals } from "@mantine/modals";
import { ActionButton } from "~/components/Button/Button";
import { Code, PinInput, Stack, Text } from "@mantine/core";

export default function DeletePostModal() {
  const fetcher = useFetcher();
  return modals.open({
    title: "Delete post",
    size: "xl",
    children: (
      <Stack
        align="center"
        gap="xs"
      >
        <Text>
          Are you <b>sure</b> you want to delete this post? This can&apos;t be
          undone!
        </Text>
        <Text>
          To confirm, please type <Code>delete</Code> into the field below.
        </Text>
        <fetcher.Form method="post">
          <PinInput
            my="2rem"
            type={/[deleteDELETE]/}
            length={6}
            size="lg"
            autoFocus={true}
            getInputProps={(i) => {
              return {
                placeholder: "delete"[i],
                autoComplete: "off",
              };
            }}
          />
          <ActionButton
            type="submit"
            name="intent"
            value="delete"
            variant="filled"
            color="red"
            disabled={fetcher.state === "submitting"}
            w="100%"
          >
            {fetcher.state === "submitting" ? "Deleting..." : "Confirm Delete"}
          </ActionButton>
        </fetcher.Form>
      </Stack>
    ),
  });
}
solar cloud
#

ah okay, so this comes form the modals.open from mantine, because its already rendering a open modal, so you need to handle the state change inside context consumers

#

import React, { useEffect } from 'react';
import { useFetcher } from "react-router";
import { modals } from "@mantine/modals";
import { ActionButton } from "~/components/Button/Button";
import { Code, PinInput, Stack, Text } from "@mantine/core";

export default function DeletePostModal() {
const fetcher = useFetcher();

useEffect(() => {
// Open the modal when the component is mounted
modals.open({
title: "Delete post",
size: "xl",
children: (
<Stack align="center" gap="xs">
<Text>
Are you <b>sure</b> you want to delete this post? This can't be
undone!
</Text>
<Text>
To confirm, please type <Code>delete</Code> into the field below.
</Text>
<fetcher.Form method="post">
<PinInput
my="2rem"
type={/[deleteDELETE]/}
length={6}
size="lg"
autoFocus={true}
getInputProps={(i) => {
return {
placeholder: "delete"[i],
autoComplete: "off",
};
}}
/>
<ActionButton
type="submit"
name="intent"
value="delete"
variant="filled"
color="red"
disabled={fetcher.state === "submitting"}
w="100%"
>
{fetcher.state === "submitting" ? "Deleting..." : "Confirm Delete"}
</ActionButton>
</fetcher.Form>
</Stack>
),
});
}, []); // Empty dependency array ensures this runs once when the component mounts

return null; // Component doesn't render anything directly
}

fast thunder
#

it's not already rendering a modal though?

solar cloud
#

na i mean you can only change the state before the rendering happens

#

sorry man never worked with mantine ^_^ and still just trying to help not to confuse you

#

but if you wrap this up in a useEffect you should be save that its not happening in the render cycle

#

you would just open the modal in the moment you mount your component

#

i mean im not sure if this is a good way at all, because i didnt realize that mantine got its own modalprovider

fast thunder
#

:'(

#

this still does the same thing

#

no big scary rendering error but it sets action="/"

solar cloud
#

okay lets try out this the last time, now its handled by mantines modal provider ^^

#

import React, { useState } from 'react';
import { useFetcher } from "react-router";
import { Modal, Button, Stack, Text, Code, PinInput, Notification } from '@mantine/core';

export default function DeletePostModal({ opened, onClose, postId }) {
const fetcher = useFetcher();
const [error, setError] = useState(null);

const handleDelete = () => {
fetcher.submit({ postId }, { method: 'post' }).catch(err => setError(err.message));
onClose(); // Close modal after action
};

return (
<>
{/* Show Notification for Error */}
{error && <Notification color="red" onClose={() => setError(null)}>{error}</Notification>}

  <Modal opened={opened} onClose={onClose} title="Delete Post">
    <Stack align="center" gap="xs">
      <Text>
        Are you <b>sure</b> you want to delete this post? This can&apos;t be undone!
      </Text>
      <Text>
        To confirm, please type <Code>delete</Code> into the field below.
      </Text>
      <fetcher.Form method="post">
        <PinInput
          my="2rem"
          type={/[deleteDELETE]/}
          length={6}
          size="lg"
          autoFocus={true}
          getInputProps={(i) => {
            return {
              placeholder: "delete"[i],
              autoComplete: "off",
            };
          }}
        />
        <ActionButton
          type="submit"
          name="intent"
          value="delete"
          variant="filled"
          color="red"
          disabled={fetcher.state === "submitting"}
          w="100%"
        >
          {fetcher.state === "submitting" ? "Deleting..." : "Confirm Delete"}
        </ActionButton>
      </fetcher.Form>
    </Stack>
  </Modal>
</>

);
}

#

and this is a better way than using the useeffect -> import React, { useState } from 'react';
import { useFetcher } from "react-router";
import { Modal, Button, Stack, Text, Code, PinInput } from '@mantine/core';
import { modals } from "@mantine/modals";
import { ActionButton } from "~/components/Button/Button";

export default function DeletePostModal({ opened, onClose, postId }) {
const fetcher = useFetcher();

const handleDelete = () => {
// Handle post delete logic (e.g., make API call)
fetcher.submit({ postId }, { method: 'post' });
onClose(); // Close modal after action
};

return (
<Modal opened={opened} onClose={onClose} title="Delete Post">
<Stack align="center" gap="xs">
<Text>
Are you <b>sure</b> you want to delete this post? This can't be undone!
</Text>
<Text>
To confirm, please type <Code>delete</Code> into the field below.
</Text>
<fetcher.Form method="post">
<PinInput
my="2rem"
type={/[deleteDELETE]/}
length={6}
size="lg"
autoFocus={true}
getInputProps={(i) => {
return {
placeholder: "delete"[i],
autoComplete: "off",
};
}}
/>
<ActionButton
type="submit"
name="intent"
value="delete"
variant="filled"
color="red"
disabled={fetcher.state === "submitting"}
w="100%"
>
{fetcher.state === "submitting" ? "Deleting..." : "Confirm Delete"}
</ActionButton>
</fetcher.Form>
</Stack>
</Modal>
);
}

function ParentComponent() {
const [opened, setOpened] = useState(false);
const [postId, setPostId] = useState(null);

const openModal = (id) => {
setPostId(id);
setOpened(true);
};

const closeModal = () => {
setOpened(false);
setPostId(null); // Clear the post ID when modal closes
};

return (
<div>
{/* Example Button to Open Modal */}
<Button onClick={() => openModal(123)}>Delete Post</Button>

  {/* Pass modal open state and close handler */}
  <DeletePostModal opened={opened} onClose={closeModal} postId={postId} />
</div>

);
}

#

if this wont help you i got no more ideas 😦

fast thunder
#

so i would set the onClick of the confirm delete button to be handleDelete?

solar cloud
#

yeah i would try that

#

but no guarantee 😄

#

should be possible to pass more information there not only the id, the question is if you are hitting the right action

fast thunder
#

WHUGHXNMFBDSKJHGF

#

THAT WORKS

#

HOLY-

#

i love you

solar cloud
#

no problem man xD i guess its not that easy for RRv7 to handle stuff inside the mantine provider - but as i said maybe i get something totally twisted here xD dont mark my words 😄

fast thunder
#

it worked, that's all the proof i needed!!!!

#

i just will refactor it a bit to fit properly with how it was laid out before but that's so so so so helpful you have no idea

#

i didn't even realise there was a way to run modals without using my own modal provider like that

#

if you're ever in düsseldorf i'll buy you a beer man. thank you so much

solar cloud
#

haha ein deutscher 😄 kein thema - also ich glaube die lösung am ende ist das fetcher.submit manuell callen

fast thunder
#

you might be interested to know that actually the problem was not with using fetch instead of fetcher.submit at all

#

in fact, when i was so excited that 'it works', that's because my button was type="submit", and the request was (!) going to the clientAction on the route. handleDelete() wasn't ever called

#

setting type="button" actually threw more annoying errors

#

here is the solution that finally works for me, now all cleaned up (and simplified):

#

posts.$postId_edit.tsx

export async function clientLoader({ params }: Route.ClientLoaderArgs) {
  const res = await fetch(`/api/posts/${params.postId}`);
  const post = (await res.json()) as Post;
  return post;
}

export async function clientAction({
  request,
  params,
}: Route.ClientActionArgs) {
  const data = await request.formData();
  const id = params.postId;
  const intent = data.get("intent");

  // Prevent intent being passed to backend as field to update
  data.delete("intent");

  if (intent === "edit") {
    return await fetch(`/api/posts/${id}/edit`, {
      method: "POST",
      body: data,
    });
  } else if (intent === "delete") {
    const confirmation = data.get("confirmation");
    if (
      typeof confirmation === "string" &&
      confirmation.toLowerCase() === "delete"
    ) {
      await fetch(`/api/posts/${id}/delete`, {
        method: "POST",
      });
      return redirect("/");
    } else {
      return { ok: false, error: "Please confirm deletion" };
    }
  } else {
    throw new Error(`Invalid intent!`);
  }
}

export default function ShowPost({ loaderData }: Route.ComponentProps) {
  const post = loaderData;
  const fetcher = useFetcher();

  return (
    <fetcher.Form method="post">
      <DeletePostButton />
      <ActionIcon
        type="submit"
        name="intent"
        value="edit"
      >
        Submit
      </ActionIcon>
      {/* --- edit fields --- */}
    </fetcher.Form>
  );
}
#

DeletePostButton.tsx

export default function DeletePostButton() {
  const fetcher = useFetcher();
  const [opened, { open, close }] = useDisclosure(false);

  let confirmDeletionError = false;
  if (
    "error" in fetcher.data &&
    (fetcher.data as { ok: boolean; error: string }).error ===
      "Please confirm deletion"
  ) {
    confirmDeletionError = true;
  }

  return (
    <>
      <Button onClick={open}>
        Delete
      </Button>

      <Modal
        opened={opened}
        onClose={close}
      >
        <Text>
          Are you sure you want to delete this post?
        </Text>
        <Text {...(confirmDeletionError ? { c: "red" } : {})}>
          To confirm, please type <Code>delete</Code> into the field below.
        </Text>
        <fetcher.Form method="post">
          <PinInput
            length={6}
            name="confirmation"
          />
          <Button
            type="submit"
            name="intent"
            value="delete"
            disabled={fetcher.state === "submitting"}
          >
            Permanently delete this post
          </Button>
        </fetcher.Form>
      </Modal>
    </>
  );
}