When building applications with Refine and Firebase, managing array fields within a single Firestore document can be tricky. If you've tried to add items to an array and ended up accidentally creating new documents, this guide is for you.
🎯 Understanding the Challenge
While working with Refine and Firebase, I encountered a tricky situation. Imagine you're building an education platform where each teacher's profile is stored as a document in Firestore. The document contains multiple array fields like:
- certifications: List of teaching certifications
- subjects: Subjects they can teach
- availableGrades: Grade levels they work with
interface TeacherProfile {
id: string;
name: string;
certifications: string[];
subjects: string[];
availableGrades: string[];
}
Here's where it got interesting:
By default, when you try to add new items to these arrays using standard Refine methods, you might end up creating new documents instead of updating the arrays in the existing document. This was definitely not what I wanted!
This happens because Refine's default create/update behavior isn't optimized for this specific use case with Firestore arrays.
What I needed was:
- ✔️ Add items to arrays
- ✔️ Edit existing items
- ✔️ Delete items
- ✔️ All operations should happen in the SAME document
- ❌ NO new documents should be created
Think of it like this: Instead of creating a new page in a book every time you want to add a note, you want to add, edit, or remove notes from an existing page.
💡 The Solution Approach
The key insight was to treat all operations (Create, Read, Update, Delete) as "updates" to the existing document. Instead of using separate create/update operations, every action would be an update to our single document's array fields.
🔑 Key Implementation Details
- Single Document Focus: All operations target one specific document ID
- Array Field Updates: Every operation (even additions) is treated as an update
- Field Isolation: Updates only affect the specific array being modified
Here's how we made it work:
const updateField = async (newArray: string[]) => {
try {
await mutate(
{
resource: "teachers",
id: configId, // Always the same document ID!
values: {
[fieldName]: newArray // Only update this specific array
},
},
{
successNotification: false,
errorNotification: false,
onSuccess: () => {
message.success(`${title} updated successfully`);
setIsModalVisible(false);
form.resetFields();
onSuccess?.();
},
onError: (error) => {
console.error("Update error:", error);
message.error(`Error updating ${title}`);
},
}
);
} catch (error) {
console.error("Mutation error:", error);
message.error(`Error updating ${title}`);
}
};
The magic happens in how we handle different operations:
// Adding an item (still an update!)
const handleAdd = async (values: { item: string }) => {
const updatedItems = [...(data || []), values.item];
await updateField(updatedItems); // Uses update operation
};
// Editing an item
const handleEdit = async (values: { item: string }) => {
if (editingItem === null) return;
const updatedItems = [...data];
updatedItems[editingItem.index] = values.item;
await updateField(updatedItems); // Same update operation
};
// Deleting an item
const handleDelete = async (index: number) => {
const updatedItems = data.filter((_, i) => i !== index);
await updateField(updatedItems); // Again, same update operation
};
🔧 The RequestPayloadFactory: The Missing Piece
A crucial part of making this solution work is the requestPayloadFactory
. This factory ensures that when we update an array field, we ONLY update that specific field without touching the rest of the document. Here's how we implemented it:
// List all array fields for type safety
const ARRAY_FIELDS = [
"certifications",
"subjects",
"availableGrades"
] as const;
export const requestPayloadFactory = (resource: string, payload: any) => {
switch (resource) {
case "teachers": // Your collection name
return createUpdateTeacherProfilePayload(payload);
default:
return payload;
}
};
export const createUpdateTeacherProfilePayload = (data: any) => {
// Check if we're updating an array field
const arrayFieldToUpdate = ARRAY_FIELDS.find(field => data[field] !== undefined);
if (arrayFieldToUpdate) {
// Only send the specific array field being updated
return {
[arrayFieldToUpdate]: data[arrayFieldToUpdate],
};
}
// Handle non-array updates
const {
...rest
} = data;
return {
...rest,
};
};
Why the PayloadFactory is Important
-
Field Isolation: When updating
certifications
, we don't want to accidentally modify other fields.
// What gets sent to Firestore:
{
certifications: ["item1", "item2"]
}
// Instead of the entire document!
Clean Updates: The factory removes unnecessary fields (id, timestamps) that shouldn't be part of the update.
Consistency: Every array update goes through the same process, ensuring consistent behavior across all operations.
Safety: Prevents accidental overwrites of other document fields by only sending the field being updated.
Complete Component Implementation
import React, { useState } from "react";
import {
Card,
Button,
Form,
Input,
List,
Modal,
Space,
message,
Popconfirm,
} from "antd";
import { PlusOutlined, EditOutlined, DeleteOutlined } from "@ant-design/icons";
import { useUpdate } from "@refinedev/core";
interface ArrayFieldListProps {
title: string;
fieldName: string;
data: string[];
configId: string;
onSuccess?: () => void;
}
export const ArrayFieldList: React.FC<ArrayFieldListProps> = ({
title,
fieldName,
data,
configId,
onSuccess,
}) => {
const { mutate } = useUpdate();
const [isModalVisible, setIsModalVisible] = useState(false);
const [form] = Form.useForm();
const [editingItem, setEditingItem] = useState<{
index: number;
value: string;
} | null>(null);
const updateField = async (newArray: string[]) => {
try {
await mutate(
{
resource: "teachers",
id: configId,
values: {
[fieldName]: newArray,
},
},
{
onSuccess: () => {
message.success(`${title} updated successfully`);
setIsModalVisible(false);
form.resetFields();
onSuccess?.();
},
onError: (error) => {
console.error("Update error:", error);
message.error(`Error updating ${title}`);
},
}
);
} catch (error) {
console.error("Mutation error:", error);
message.error(`Error updating ${title}`);
}
};
const handleAdd = async (values: { item: string }) => {
const updatedItems = [...(data || []), values.item];
await updateField(updatedItems);
};
const handleEdit = async (values: { item: string }) => {
if (editingItem === null) return;
const updatedItems = [...data];
updatedItems[editingItem.index] = values.item;
await updateField(updatedItems);
setEditingItem(null);
};
const handleDelete = async (index: number) => {
const updatedItems = data.filter((_, i) => i !== index);
await updateField(updatedItems);
};
return (
<Card
title={title}
extra={
<Button
type="primary"
style={{
backgroundColor: "#ff9028",
color: "black",
}}
icon={<PlusOutlined />}
onClick={() => {
setEditingItem(null);
form.resetFields();
setIsModalVisible(true);
}}
>
Add {title}
</Button>
}
>
<List
dataSource={data || []}
renderItem={(item, index) => (
<List.Item
actions={[
<Button
icon={<EditOutlined />}
size="small"
onClick={() => {
setEditingItem({ index, value: item });
form.setFieldsValue({ item: item });
setIsModalVisible(true);
}}
/>,
<Popconfirm
title="Delete the item"
description="Are you sure to delete this item?"
onConfirm={() => handleDelete(index)}
okText="Yes"
cancelText="No"
okButtonProps={{ danger: true }}
>
<Button icon={<DeleteOutlined />} size="small" danger />
</Popconfirm>,
]}
>
<List.Item.Meta
title={<span style={{ fontWeight: "normal" }}>{item}</span>}
/>
</List.Item>
)}
/>
<Modal
title={editingItem ? `Edit ${title}` : `Add ${title}`}
open={isModalVisible}
onCancel={() => {
setIsModalVisible(false);
setEditingItem(null);
form.resetFields();
}}
footer={null}
>
<Form
form={form}
onFinish={editingItem ? handleEdit : handleAdd}
layout="vertical"
>
<Form.Item
name="item"
label={title}
rules={[
{
required: true,
message: `Please enter ${title.toLowerCase()}`,
},
]}
>
<Input placeholder={`Enter ${title.toLowerCase()}...`} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
{editingItem ? "Update" : "Add"}
</Button>
<Button
onClick={() => {
setIsModalVisible(false);
setEditingItem(null);
form.resetFields();
}}
>
Cancel
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
</Card>
);
};
🌟 Why This Works
This approach works because:
- We're always targeting the same document ID
- Every operation is an "update" operation
- We only send the specific array field being modified
- We never try to create new documents
🚀 Using the Component
Here's how to use the component in your teacher profile page:
const TeacherProfile = () => {
const { tableProps, tableQueryResult } = useTable({
syncWithLocation: true,
meta: {
collection: "teachers",
},
});
const configId = "your-doc-id"; // Your single document ID
const items = [
{
key: "1",
label: "Certifications",
children: tableProps.dataSource?.[0] && (
<ArrayFieldList
title="Certification"
fieldName="certifications"
data={tableProps.dataSource[0].certifications || []}
configId={configId}
onSuccess={() => tableQueryResult.refetch()}
/>
),
},
// ... other array fields
];
return (
<div style={{ padding: "24px" }}>
<Card title="Teacher Profile">
<Tabs defaultActiveKey="1" items={items} />
</Card>
</div>
);
};
🎯 Results
With this solution:
- ✅ All CRUD operations happen within the same document
- ✅ No accidental document creation
- ✅ Clean, reusable component for any array field
- ✅ Type-safe implementation
💭 Share Your Thoughts
Have you ever faced the challenge of managing array fields in a single Firestore document? How did you handle it? Let me know in the comments! 👇
Top comments (0)