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
.
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>
</>
);
},
);
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.
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>
);
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]);