Functional vs. Direct Update In React

2024-10-29
#english #react

TL;DR

It's recommended to use Functional State Update in React.

Intro

Lately, i've been working on React stuff. And once i made pr, what contains code like the one below

const [open, setOpen] = React.useState(false);

setOpen(!open) // 2

My co-worker pointed out that it'is better to write code this way:

setOpen(prev => !prev) // 1

I’m just curious about the difference between these two cases, so I dug into it and searched online. Some of the articles said that functional state updates have many advantages:

  1. Consistency and Reliability
  2. Performance Optimization
  3. Readability and Maintainability

Emmm, it seems kind of useless. The main reason we should use Functional State Update is to avoid race conditions.

Functional State vs. Direct Update

All of us should keep in mind that setState in React is asynchronous. So if we don't use it correctly , it may cause unexpected problem.

Example

import React, { useState } from 'react';

export function App(props) {

	const [counter, setCounter] = useState(0);

	return (
			<div className='App'>
			<h1>${counter}</h1>
			<button
			onClick={() => {
				setCounter(counter + 1);
				setCounter(counter + 1);
				
			}}>		
	        Add
	        </button>
	       </div>
		);

}

Let's run the example

directly-update.mp4

the result is not what we expected. To resolve this issue, what we need to do is use Functional State Update

setCounter((prevCount) => prevCount + 1);
setCounter((prevCount) => prevCount + 1);

Same here, let's run the code

functional-state-update.mp4

Is that it? Wait, how could you such terrible code in real life ? Even a beginner could't write such bad code.

Real Life Case

import { useState, useEffect } from 'react';
import { AlertCircle } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';

const AsyncCartExample = () => {
  const [regularCart, setRegularCart] = useState({
    quantity: 0,
    pendingRequests: 0,
    successfulRequests: 0
  });
  
  const [functionalCart, setFunctionalCart] = useState({
    quantity: 0,
    pendingRequests: 0,
    successfulRequests: 0
  });

  // Simulates an API call with random delay
  const simulateAPICall = () => {
    return new Promise(resolve => {
      setTimeout(() => {
        resolve();
      }, Math.random() * 1000); // Random delay 0-1000ms
    });
  };

  // ❌ Problematic approach - direct state updates
  const addToRegularCart = async () => {
    setRegularCart({
      ...regularCart,
      pendingRequests: regularCart.pendingRequests + 1
    });

    await simulateAPICall();

    setRegularCart({
      ...regularCart, // Uses stale closure value!
      quantity: regularCart.quantity + 1,
      pendingRequests: regularCart.pendingRequests - 1,
      successfulRequests: regularCart.successfulRequests + 1
    });
  };

  // ✅ Better approach - functional updates
  const addToFunctionalCart = async () => {
    setFunctionalCart(current => ({
      ...current,
      pendingRequests: current.pendingRequests + 1
    }));

    await simulateAPICall();

    setFunctionalCart(current => ({
      ...current,
      quantity: current.quantity + 1,
      pendingRequests: current.pendingRequests - 1,
      successfulRequests: current.successfulRequests + 1
    }));
  };

  // Function to add multiple items quickly
  const addMultipleItems = (useRegular) => {
    const addFunction = useRegular ? addToRegularCart : addToFunctionalCart;
    // Add 5 items in rapid succession
    for (let i = 0; i < 5; i++) {
      addFunction();
    }
  };

  return (
    <div className="p-6 max-w-2xl mx-auto space-y-8">
      <Alert>
        <AlertCircle className="h-4 w-4" />
        <AlertDescription>
          Click "Add 5 Items" quickly multiple times to see the difference between regular and functional updates
        </AlertDescription>
      </Alert>
      
      <div className="grid grid-cols-2 gap-8">
        <div className="p-4 border rounded-lg">
          <h2 className="text-xl font-bold mb-4">Regular Updates</h2>
          <div className="space-y-2">
            <div>Quantity: {regularCart.quantity}</div>
            <div>Pending: {regularCart.pendingRequests}</div>
            <div>Successful: {regularCart.successfulRequests}</div>
            <button
              onClick={() => addMultipleItems(true)}
              className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded"
            >
              Add 5 Items
            </button>
          </div>
        </div>

        <div className="p-4 border rounded-lg">
          <h2 className="text-xl font-bold mb-4">Functional Updates</h2>
          <div className="space-y-2">
            <div>Quantity: {functionalCart.quantity}</div>
            <div>Pending: {functionalCart.pendingRequests}</div>
            <div>Successful: {functionalCart.successfulRequests}</div>
            <button
              onClick={() => addMultipleItems(false)}
              className="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded"
            >
              Add 5 Items
            </button>
          </div>
        </div>
      </div>
    </div>
  );
};

export default AsyncCartExample;

Go as routine

a-more-realistic-example.mp4

From the example above, i think it's quite clear why should we use Functional State Update rather than Direct Update.

Reference

  1. Functional State Updates in React: A Guide to When and How to Use Them
  2. Functional State Update in React