Search code examples
javascriptreactjsreact-hooks

Modal not Closing After Response in React


I'm using useImperativeHandle and ref to let the parent have access to some functions. I'm wondering why the modal here is not closing? I tried to place alert and console.log and it works but the setIsEditOpen() and setIsDeleteOpen() is not working inside the closeModals.

STACKBLITZ

Product.tsx

export default function Products() {
    const { products } = useLoaderData<typeof loader>();
    const actionData = useActionData<typeof action>();
    const productItemRef = useRef<ModalRef>(null);

    useEffect(() => {
        if (actionData?.ok) {
            productItemRef?.current.closeModals();
        }
      }, [actionData]);

    return (
        <div className="divide-y">
            {products.map((product) => (
                <ProductItem
                    key={product.id}
                    product={product}
                    ref={productItemRef}
                    lastResult={actionData}
                />
            ))}
        </div>
    )

ProductItem.tsx

type ProductItemProps = {
    product: {
        id: string;
        name: string;
        description: string;
        price: number;
        category: string;
    };
    lastResult?: any;
};

export type ModalRef = {
    closeModals: () => void;
};

export const ProductItem = forwardRef(
    ({ product, lastResult }: ProductItemProps, ref: Ref<ModalRef>) => {
        const [isEditOpen, setIsEditOpen] = useState(false);
        const [isDeleteOpen, setIsDeleteOpen] = useState(false);
        const { name, description, price, category } = product;

        useImperativeHandle(ref, () => ({
            closeModals: () => {
                setIsEditOpen(false);
                setIsDeleteOpen(false);
            },
        }));

        return (
            <>
                <ReusableSheet
                    isOpen={isEditOpen}
                    setIsOpen={setIsEditOpen}
                    title="Edit a product"
                >
                    <EditProductForm
                        setIsOpen={setIsEditOpen}
                        product={product}
                        lastResult={lastResult}
                    />
                </ReusableSheet>
                <ReusableDialog
                    isOpen={isDeleteOpen}
                    setIsOpen={setIsDeleteOpen}
                    title="Remove a product from the list"
                    description={
                        <span>
                            Are you sure you want to remove{" "}
                            <span className="font-bold">{name}</span>{" "}
                            from the list of products?
                        </span>
                    }
                    hideCloseButton
                >
                    <DeleteProductForm setIsOpen={setIsDeleteOpen} product={product} />
                </ReusableDialog>
            </>
        );
    },
);

Solution

  • Issue

    You are creating and referencing only a single React ref in the products file and passing it to each mapped ProductItem from the products array. This means that each <ProductItem ref={productItemRef} .... /> overwrites the ref value and the last item mapped is what productItemRef has a reference to. When you are clicking the "save" button of say the "Banana" product edit modal, the ref is trying to call closeModals of the "Mango" product edit modal, and the "Banana" modal is never closed.

    Solution Suggestion

    Creating the Modal Refs

    At a minimum you'll need to create a React ref for each mapped ProductItem so they can be individually referenced.

    Example:

    // Array of React refs
    const productItemRef = useRef<React.RefObject<ModalRef>[]>([]);
    
    // Set modal refs to current value or create if necessary
    productItemRef.current = products.map(
      (_, i) => productItemRef.current[i] ?? createRef()
    );
    
    ...
    
    return (
      <div className="flex flex-col gap-2">
        {products.map((product, i) => (
          <ProductItem
            key={product.id}
            product={product}
            ref={productItemRef.current[i]} // <-- pass modal ref
          />
        ))}
      </div>
    );
    

    Closing the Modals

    A naive/trivial approach could be to simply iterate the entire array of modal refs and call closeModals on each one.

    Example:

    useEffect(() => {
      if (actionData?.type === 'success') {
        productItemRef.current.forEach((productRef) =>
          productRef.current?.closeModals()
        );
      }
    }, [actionData]);
    

    An improved approach would be to include the edited product id in the action data.

    Example:

    EditProductForm - Include a hidden field that passes the product.Id in the form data, accessible in the action handler.

    export function EditProductForm({ setIsOpen, product }: EditProductFormProps) {
      const navigation = useNavigation();
    
      const isUpdating =
        navigation.formData?.get('intent') === 'edit-product' &&
        navigation.state === 'submitting';
    
      return (
        <Form method="post">
          <div className="flex justify-end gap-2 mt-2">
    
            {/* Hidden field to include with form data */}
            <input name="productId" defaultValue={product.id} hidden />
    
            {product.name}
            <Button
              type="button"
              variant="outline"
              disabled={isUpdating}
              onClick={() => setIsOpen(false)}
            >
              Cancel
            </Button>
            <Button
              name="intent"
              value="edit-product"
              disabled={isUpdating}
              type="submit"
            >
              {isUpdating ? (
                <Loader2 className="mr-2 h-4 w-4 animate-spin" />
              ) : null}
              Save
            </Button>
          </div>
        </Form>
      );
    }
    

    Product index file - Access the product id and return with JSON data response.

    export const action = async ({ request, context }: ActionFunctionArgs) => {
      const formData = await request.formData();
      const productId = formData.get("productId"); // <-- get product id
      const intent = formData.get('intent');
    
      if (intent === 'edit-product') {
        return json({
          productId, // <-- return product id
          type: 'success',
        });
      }
    
      return null;
    };
    

    Close just the product edit modal that was open

    useEffect(() => {
      if (actionData?.type === 'success') {
        productItemRef.current[
          products.findIndex((product) => product.id === actionData?.productId)
        ].current?.closeModals();
      }
    }, [actionData]);