How Side Effects Spread

2023-05-09

#fp #javascript 

I’m working on some larger posts about functional programming in javascript. In the meantime, here’s an observation on the pervasiveness of side-effects in functional javascript code. If you’re interested in learning more about this topic, I suggest reading the book grokking simplicity.

Asynchronous javascript

Let’s say we have a UI that displays a list of coupons. Users can submit new coupons to the list and, if the coupon isn’t in the database already, it will create a database record and add it to the list in the UI.

We have an async function to check if a coupon exists:

const doesCouponExist = async (couponCode) => {
  try {
    const coupon = await db.getCoupon(couponCode);
    return Boolean(coupon);
  } catch (e) {
    console.error("Unable to fetch coupon", e);
    return false;
  }
};

And another small async function to create a coupon (in the case that it doesn’t exist):

const createCoupon = async (couponCode) => {
  const coupon = await db.createCoupon(couponCode);
  return coupon;
};

Lastly, we need a function to handle capture the main use case: create a new coupon if needed and add it to the list:

const addCouponToList = (couponCode, list) => {
  // doesCouponExist returns a promise, this if will always evaluate
  // to false!
  if (!doesCouponExist(couponCode)) {
    // coupon will _not_ be the coupon, it will be a promise!
    const coupon = createCoupon(couponCode);
    ui.addElement(list, coupon);
  }
};

Unfortunately, this will not work because the async functions that we are calling return promises, not values. We need to await those if we intend to use the values. Any time we use an asynchronous function, the calling function needs to be asynchronous as well. Asynchronous code spreads.

Here’s the updated function:

const addCouponToList = async (couponCode, list) => {
  try {
    const doesExist = await doesCouponExist(couponCode);
    if (!doesExist) {
      const coupon = await createCoupon(couponCode);
      ui.addElement(list, coupon);
    }
  } catch (e) {
    console.error("Unable to add coupon to the list", e);
  }
};

Side effects

Asynchronous functions are an example of an “impure” functions - those that produce “side effects”. Here’s the definition for what a function with side effects is according to wikipedia:

[…] it modifies some state variable value(s) outside its local environment, which is to say if it has any observable effect other than its primary effect of returning a value to the invoker of the operation.

I used async functions as an example, but there are plenty of other operations that produce side effects:

Functions with side effects are, among other things, very hard to test. We need to write mocks to handle any assumed global state and external functionality. The more side effects, the more setup is needed before tests can be written. It’s one of the reasons why testing UI code is (often) a pain.

Silently spreading

Functions with side effects spread throughout a codebase exactly like async functions. If a function is “impure” (contains side effects), then any function that calls that function is impure too.

Unlike asynchronous code, Javascript does not provide a way to label a function as having a side effect. We don’t have an identifier like async to help us out - it’s up to the engineer to identify where side effects happen and how they’re spreading. Here’s an example that includes an impure function and another function that makes use of it:

const shoppingCart = [];

// Has side effects
const addProductToCart = (productId) => {
  // shoppingCart is an implicit input: we need it
  // but it's not passed in explicitly as a function
  // argument.
  shoppingCart.push(productId);
  // ui is an implicit output, it is also not passed
  // to this function as an argument.
  ui.update();
};

// Has side effects because it contains a function that
// contains side effects.
const handleAddButtonClick = (btn) => {
  const productId = btn.getAttribute("product-id");
  addProductToCart(productId);
};

Any time you use a variable that was defined outside the scope of a function (implicit input), that function has side effects. The function will behave differently based on the value of that variable. Similarly, any time you write to a variable that was created outside the scope of a function (implicit output), that function has side effects.

In this example, it’s pretty obvious where the side effects live. It’s a lot less obvious when you’re relying on libraries that may abstract away implementation details that include them.

It’s for this reason that I struggle with using “context” in React (and useState / useEffect). It makes it very hard to separate the pure functions from the functionality that has side effects. Anyone who’s written a unit test for a component that makes heavy use of hooks knows this pain.

Libraries like jotai further muddy the water by wrapping all of your data in “atoms” that require a side effect to unwrap.

Advice

Side effects are unavoidable: that’s how we do stuff! Without them, we would be unable to update our UI, use random numbers, or handle user interaction. That said, we should take care about how many we introduce. Furthermore, if a function has side effects, let’s try to limit its responsibility to only the side effect. Here’s the above example with some adjustments:

// No more side effects!
// Explicit input: supply a shopping cart
const addProductToCart = (cart, productId) => {
  // Using spread here to make a new version of the shopping
  // cart (copy-on-write pattern).
  const newCart = [...cart, productId];
  // Explicit output: return the new cart
  return newCart;
};

// Still has side effects, but limited to just UI
const handleAddButtonClick = (btn, cart) => {
  const productId = btn.getAttribute("product-id");
  const newCart = addProductToCart(cart, productId);
  ui.update(newCart);
};

addProductToCart is now trivial to test. Additionally, all the UI side-effects are contained in one place. This makes it easier for us to swap out the display layer (maybe we want to create a PDF of the list whenever it changes).