DEV Community

Refine + Firestore: Mastering Array Field Updates in a Single Document

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[];
}
Enter fullscreen mode Exit fullscreen mode

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

  1. Single Document Focus: All operations target one specific document ID
  2. Array Field Updates: Every operation (even additions) is treated as an update
  3. 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}`);
  }
};
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

🔧 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,
  };
};
Enter fullscreen mode Exit fullscreen mode

Why the PayloadFactory is Important

  1. 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!
Enter fullscreen mode Exit fullscreen mode
  1. Clean Updates: The factory removes unnecessary fields (id, timestamps) that shouldn't be part of the update.

  2. Consistency: Every array update goes through the same process, ensuring consistent behavior across all operations.

  3. 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>
  );
};
Enter fullscreen mode Exit fullscreen mode

🌟 Why This Works

This approach works because:

  1. We're always targeting the same document ID
  2. Every operation is an "update" operation
  3. We only send the specific array field being modified
  4. 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>
  );
};
Enter fullscreen mode Exit fullscreen mode

🎯 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)