DEV Community

Cover image for The Hidden Dangers of Shallow Copying in JavaScript: Lessons from API Response Handling
Akshat Soni
Akshat Soni

Posted on • Edited on

The Hidden Dangers of Shallow Copying in JavaScript: Lessons from API Response Handling

Understanding Shallow vs Deep Copying in JavaScript: A Next.js API Example

One common pitfall I encounter is the difference between shallow and deep copying of objects, especially when dealing with nested data structures. This blog post will explore this issue through a real-world example involving an API response in a Next.js project.

Let's imagine that you have a default API response object defined as follows:

const defaultApiResponse: ApiResponse<any> = {
  data: [],
  success: false,
  message: "No data found",
  errors: [],
  status: 404
};
Enter fullscreen mode Exit fullscreen mode

Now, you create an API endpoint that uses this default response as a template for building actual responses:

import { NextRequest, NextResponse } from 'next/server';
import prisma from '../../../../database/prismaClient';
import { defaultApiResponse } from '../ApiResponseType';

export async function GET(req: NextRequest) {
  const apiResponse = { ...defaultApiResponse };

  try {
    const { searchParams } = new URL(req.url);
    let product_id = searchParams.get('product_id');
    if (!product_id || isNaN(Number(product_id))) {
      apiResponse.errors.push("Product ID is required and must be a number");
      apiResponse.success = false;
      apiResponse.status = 400;
      return NextResponse.json(apiResponse);
    }
    const product = await prisma.product.findUnique({
      where: { id: Number(product_id) }
    });
    if (!product) {
      apiResponse.errors.push("Product not found");
      apiResponse.success = false;
      apiResponse.status = 404;
    } else {
      apiResponse.data.push(product);
      apiResponse.success = true;
      apiResponse.status = 200;
      apiResponse.message = "Product found";
    }
  } catch (error) {
    apiResponse.success = false;
    apiResponse.status = 500;
    apiResponse.errors.push(error.message || "An unknown error occurred");
  }
  return NextResponse.json(apiResponse);
}
Enter fullscreen mode Exit fullscreen mode

Initially, this code seems correct. However, upon making several requests, you notice that the apiResponse object retains data from previous requests.

Why is this happening? 🤔

Due to Shallow Copy

But wait, what is Shallow copy? 🤔

Understanding Shallow Copy

The line:

const apiResponse = { ...defaultApiResponse };
Enter fullscreen mode Exit fullscreen mode

uses the spread operator to create a new object. This is a shallow copy, meaning it only copies the top-level properties.

If any of these properties are objects or arrays (like data and errors), the new object will reference the same memory locations as the original.

Thus, modifying apiResponse.errors or apiResponse.data will also modify defaultApiResponse.errors and defaultApiResponse.data, leading to a persistent state across requests.

The Solution: Deep Copy

To prevent this issue, you need to ensure that each request gets a completely independent copy of the defaultApiResponse, including its nested arrays and objects.

One way to achieve this is by using a utility function to create a fresh copy:

Step 1: Create a utility function

// utils/apiResponse.ts
import { ApiResponse } from '../ApiResponseType';

export function getDefaultApiResponse<T>(): ApiResponse<T> {
  return {
    data: [],
    success: false,
    message: "No data found",
    errors: [],
    status: 404
  };
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Use the utility function in your API handler

import { NextRequest, NextResponse } from 'next/server';
import prisma from '../../../../database/prismaClient';
import { getDefaultApiResponse } from '../../../../utils/apiResponse';

export async function GET(req: NextRequest) {
  const apiResponse = getDefaultApiResponse<any>();

  try {
    const { searchParams } = new URL(req.url);
    let product_id = searchParams.get('product_id');
    if (!product_id || isNaN(Number(product_id))) {
      apiResponse.errors.push("Product ID is required and must be a number");
      apiResponse.success = false;
      apiResponse.status = 400;
      return NextResponse.json(apiResponse);
    }
    const product = await prisma.product.findUnique({
      where: { id: Number(product_id) }
    });
    if (!product) {
      apiResponse.errors.push("Product not found");
      apiResponse.success = false;
      apiResponse.status = 404;
    } else {
      apiResponse.data.push(product);
      apiResponse.success = true;
      apiResponse.status = 200;
      apiResponse.message = "Product found";
    }
  } catch (error) {
    apiResponse.success = false;
    apiResponse.status = 500;
    apiResponse.errors.push(error.message || "An unknown error occurred");
  }
  return NextResponse.json(apiResponse);
}
Enter fullscreen mode Exit fullscreen mode

Now you need to think about Why This Works. 🤔

So to answer this:

The utility function getDefaultApiResponse ensures that every request handler invocation starts with a fresh, deep copy of the default response. This prevents shared references and ensures that modifications to apiResponse do not affect defaultApiResponse or other instances of apiResponse.

Conclusion

Understanding the difference between shallow and deep copying in JavaScript is crucial when dealing with objects and arrays. Shallow copies can lead to unintended side effects, especially in stateful applications like APIs.

By using utility functions to create fresh copies of default objects, you can avoid these pitfalls and ensure your code remains clean and maintainable.

Thanks for reading my post. If you have any questions regarding this, let me know.

Top comments (0)