Promise chaining - Code Review

Promise chaining - Code Review

Tags
Node.js
JavsScript
vue
Published
October 11, 2024
Author
I was working with a Vue component. The goal of the component was very simple:
  1. If the URL includes the right query parameters, then it will send a POST request to the backend
  1. Otherwise, it will throw you to the Generic error page.
 
Sounds simple, right?. Here is what was done in the component.
 
<template> <div class="ek-container"> <component :is="WidgetComponents[getWidgetComponents()]" :customer="currentCustomer" :agreement="currentAgreement" :originalAgreement="originalAgreement" :product="currentProduct" :originalProduct="originalProduct" :insuredObject="currentInsuredObject" :originalInsuredObject="originalInsuredObject" :coverage="currentCoverage" :originalCoverage="originalCoverage" :isLoadingDetails="isLoadingDetails" :errorDetails="errorDetailsComputed" /> </div> </template> <script> function getWidgetComponents() { const productCode = currentProduct.value?.code; if ( !showErrorPage.value && hasValidURLparams.value && hasValidProduct.value ) { if (productCode === ProductCodes.PREGNANCY) { return "PregnancyWidget"; } } return "GenericErrorPage"; } ... other codes </script>
 
This code defines a Vue.js component template with a container that dynamically renders different widgets based on the product's status and type, specifically checking if it’s a "Pregnancy" product. Here's a breakdown of the code's functionality, as well as positive and negative points:

Code Summary

  1. Template: A div container with a dynamic component (<component>) that conditionally renders components based on getWidgetComponents(). It passes several props (customer, agreement, product, etc.) to the rendered component.
  1. Dynamic Component Resolution: The getWidgetComponents() function determines the component type based on:
      • Validity of URL parameters (hasValidURLparams).
      • Product validity (hasValidProduct).
      • Whether productCode matches ProductCodes.PREGNANCY.
  1. Conditional Rendering: If conditions are met and the product code is "Pregnancy," the PregnancyWidget component is shown; otherwise, it defaults to GenericErrorPage.

Positive Points

  • Modular and Reusable: Using dynamic components (:is) allows flexibility to display different widgets based on business logic, making it highly reusable.
  • Error Handling: The fallback GenericErrorPage provides a safe default if parameters are invalid or the product doesn’t match.
  • Parameterization: Passing specific props (customer, agreement, etc.) enables flexibility for the child component to use relevant data.

Negative Points

  • Tight Coupling of Conditions: The component heavily relies on a specific product code for conditional rendering. Adding new conditions (like other product types) may require more code duplication or refactoring.
  • Limited Extensibility: If new product types require new widgets, the getWidgetComponents() function could become cluttered and harder to maintain.
  • Code Organization: getWidgetComponents is embedded in the script without proper separation, making it less modular. Extracting it to a helper or computed property would improve readability and testability.

Recommendations

Consider refactoring getWidgetComponents to make it easier to add new conditions, potentially using a mapping approach for products to widget components. Also, consider modularizing the logic to improve readability and maintainability.
 
In any case, we discard above issues and move head. Assuming, all query parameters are provided, we are supposed to create offer request. That could be done by sending POST request to backend. Here, is how it was implemented:
 
And onMounted Layer 1:
onMounted(async () => { ciaStore.showInlineError = false; const areRequestSame = areSameURLParamsRequest( ciaStore.lastValidURLParams, props.widgetProps ?? ({} as WidgetProps) ); const isSystemError = ErrorCode.SYSTEM_ERROR.includes( ciaStore.lastErrorCode ); const shouldSendRequest = hasValidURLparams?.value && hasValidProduct.value && (!areRequestSame || isSystemError); if (shouldSendRequest) { ciaStore.$reset(); ciaStore.lastValidURLParams = props.widgetProps ?? ({} as WidgetProps); await createChangeOfferRequest().catch( (errors: Error | any) => { setTimeout(() => { const errorDetails = getErrorMessageDetails( errors?.message ?? "", ciaStore.commonDrupalText as CommonDrupalText ); errorDetailsRef.value = errorDetails; ciaStore.isFullscreenLoading = errorDetails.errorKey === ErrorKey.SystemErrors; }, 300); } ); } });
 
Why is that issue?
  1. Why do we need .then if you’re already using await. The await keyword pauses execution until the createChangeOfferRequest() promise resolves or rejects, allowing you to handle the result or error directly without chaining .then or .catch. However, if you want to catch errors when using await, you can use a try...catch block instead of .catch.
  1. We are using setTimeout. We can not trust setTimeout as it can lead to a lot of side effects. JavaScript is single-threaded. Meaning, it can only calculate one thing at a time. But now imagine you have a setTimeout that is suppose to fire after 10000ms or 10s. So now JS have to keep track of the time passed. But in between that 10s the user might do some interaction with your page. Now JavaScript have to react to them as well. Here is a very nice blog article about why we should be careful with setTimeout.
 
The component logic seems overly complex for its intended purpose. Let's break down the issues with this implementation:
  1. The use of multiple nested functions and promise chains makes the code difficult to read and maintain.
  1. Error handling is scattered across different layers, making it challenging to manage and debug effectively.
  1. The reliance on setTimeout for error handling introduces potential timing issues and unnecessary delays.
 
Layer 2: And then defining the function createChangeOfferRequest
async function createChangeOfferRequest() { if ( props.widgetProps?.agreementId && props.widgetProps?.customerNo ) { const payload = { policyNumber: props.widgetProps?.agreementId, customerId: props.widgetProps?.customerNo, customerType: "privateCustomer", changeStartDate: moment .utc() .format("YYYY-MM-DDTHH:mm:ss.SSS[Z]") }; await ciaStore.createChangeOffer(payload); } }
 
What is the issue here?
  1. First of all, why do we need this second layer of function?
 
Layer 3: And then, ciaStore which is a Pinia store.
export const changeOfferActions = { async createChangeOffer( changeOfferRequest: ChangeOfferRequest ): Promise<void> { const state = useChangeOfferStore(); try { state.isFullscreenLoading = true; const changeOfferResponse = await createChangeOffer( changeOfferRequest ); updateEntities( state.originalEntities, changeOfferResponse ); updateEntities(state.entities, changeOfferResponse); state.offerId = changeOfferResponse.agreement?.offerNo ?? ''; state.customerId = changeOfferRequest.customerId ?? ''; } catch (error) { logInDev('Error creating change offer:', error); throw error; } state.isFullscreenLoading = false; }, .... };
Why could this be an issue?
  1. Why do we need this third layer?
  1. Issue: state.isFullscreenLoading is set to false only after the try...catch block, meaning it will remain true if an error occurs.
  1. Encapsulated State Updates: Keeping state mutation logic in dedicated state functions, like updateEntities, isolates side effects and improves testability.
 
Layer 4: Actual createChangeOffer method.
export async function createChangeOffer( changeOfferRequest: ChangeOfferRequest ): Promise<Offer> { return new Promise(async (resolve, reject) => { await POST("/api/changePolicy/createChangeOffer", { headers: getAPIHeaders(), body: changeOfferRequest }) .then((responseFromServer) => { if (responseFromServer.error) { reject(responseFromServer.error); } else { resolve(responseFromServer.data as Offer); } }) .catch((error) => { if ( error instanceof TypeError && error.message === "Failed to fetch" ) { reject(new Error(ErrorCode.SYSTEM_ERROR)); } else { reject(error); } }); }); }
Why is issue with this?
  1. First of all, given the goal of the component, why do we need this?
  1. Issue: Wrapping async within new Promise is redundant and unnecessary, as async functions inherently return promises. This leads to more complex code than needed.
  1. Issue: The current function handles only a TypeError with a specific message ("Failed to fetch"). It’s best to broaden the error handling to catch other network-related issues (e.g., 5xx server errors, bad request errors). Solution: Check the response status and structure the error handling to cover more cases.
  1. Issue: Using .then() and .catch() within an async function reduces readability, as await with try...catch is generally clearer for handling asynchronous code.
 
 
Layer 5: The final `POST` call
import createClient, { Middleware } from "openapi-fetch"; const apiMiddleware: Middleware = { async onRequest({ request }) { try { return request; } catch (error) { throw error; } }, async onResponse({ response }) { return response; } }; export const { POST, GET } = client;
 

Refactored Version

It felt like too much engineering to me. So, I refactored the code to introduce Tanstack vue query. And this is how my component call looks now:
const { mutate } = useMutation({ mutationFn: createChangeOffer, onSuccess: (response) => { const offerResponse = response.data as Offer; updateEntities( ciaStore.originalEntities, offerResponse ); updateEntities(ciaStore.entities, offerResponse); ciaStore.customerId = props.widgetProps?.customerNo ?? ""; ciaStore.offerId = offerResponse?.agreement?.offerNo ?? ""; ciaStore.requestState = REQUEST_STATE.SUCCESS; }, onError: (error: CustomError) => { const errorDetails = getErrorMessageDetails( error?.messageCode as ErrorCode, ciaStore.commonDrupalText as CommonDrupalText ); errorDetailsRef.value = errorDetails; ciaStore.isFullscreenLoading = errorDetails.errorKey === ErrorKey.SystemErrors; ciaStore.requestState = REQUEST_STATE.ERROR; } }); onMounted(asycn () => { // ... other checks if(valid) { const payload = { policyNumber: props.widgetProps?.agreementId, customerId: props.widgetProps?.customerNo, customerType: "privateCustomer", changeStartDate: moment .utc() .format("YYYY-MM-DDTHH:mm:ss.SSS[Z]") }; mutate(payload); } })
 
And the POST call:
export const createChangeOffer = async ( changeOfferRequest: ChangeOfferRequest ) => { return await POST("/api/changePolicy/createChangeOffer", { headers: getAPIHeaders(), body: changeOfferRequest }); };
 

Advantages

  1. Readability: Instead of a 4/5 layer of function chaining, now it just calls one function.
  1. With the Tanstack query, I can handle side effects directly without keeping too many variable references.
 
Original Code Issues
  • Unnecessary promise chaining and complex layered structure in the original code
  • Potential side effects from using setTimeout in error handling
  • Redundant wrapping of async functions within new Promise
  • Limited error handling, focusing only on specific TypeError
 
Refactored Solution
  • Introduced Tanstack vue query for improved state management and side effect handling
  • Simplified API call structure, reducing function layers
  • Enhanced readability by eliminating multiple layers of function chaining
  • Improved side effect management without relying on numerous variable references
 

Another version without third party dependencies

We can create actions in Pinia. And instead calling different layer of functions, we can directly call this action. For example, we could define action like:
import { defineStore } from 'pinia'; import axios from 'axios'; export const useProductStore = defineStore('product', { state: () => ({ products: [], isLoading: false, error: null, }), actions: { async fetchProducts() { this.isLoading = true; this.error = null; try { const response = await axios.get('/api/products'); this.products = response.data; } catch (err) { this.error = err.message; } finally { this.isLoading = false; } }, }, });
 
And can be called directly:
onMounted(() => { fetchProducts(); });
 
One of the reason why I prefer Tanstack query is due to the fact that, it handles and provides a lot of state utilities by default. For example, often we need to show loading, pending state, error state. With this library, we do not need to explicitly handle such queries.

Summary

When I refactored this codebase, there was conflict of interest by one of the senior developer. I referred it as technical depth and was strongly opposed by the reviewer saying “I am selling the wrong picture”. May be its easier because I am used to it. That lead me to think, may be its better idea for me to acknowledge myself again, research again, read again and explicitly write down my findings. This article is not mean to blame anyone, this is meant for myself so, that I can educate myself.
 
Original Implementation Issues
  • The Vue component used excessive promise chaining and complex layered structure, making the code difficult to read and maintain
  • Unnecessary use of setTimeout for error handling introduced potential timing issues
  • Multiple nested functions and scattered error handling made debugging challenging
Refactored Solution
  • Introduced Tanstack Vue Query to improve state management and simplify API calls
  • Reduced function layers and eliminated multiple layers of function chaining, enhancing readability
  • Improved side effect management without relying on numerous variable references
Alternative Approach
  • Suggested using Pinia actions for direct API calls without third-party dependencies
  • Tanstack Query preferred for its built-in state utilities, handling loading, pending, and error states automatically