Search code examples
reactjsstateimmutabilitynested-datalist

React: Change deeply nested state optimisation


I come up with a solution for change the properties in a second level nested state in React that is not scalable and it doesn't seem quite efficient. How can you do a refactor of handleOnChange method in order to change reps and weight?

import React, { useState } from "react";

const workout = {
  id: "123-234sdf-1213",
  name: "wo name",
  done: false,
  exercises: [
    {
      name: "back squat",
      sets: [
        {
          number: 0,
          reps: 0,
          weight: 0,
          done: false,
        },
        {
          number: 1,
          reps: 0,
          weight: 0,
          done: false,
        },
        {
          number: 2,
          reps: 0,
          weight: 0,
          done: false,
        },
      ],
    },
    {
      name: "leg press",
      sets: [
        {
          number: 0,
          reps: 0,
          weight: 0,
          done: false,
        },
        {
          number: 1,
          reps: 0,
          weight: 0,
          done: false,
        },
        {
          number: 2,
          reps: 0,
          weight: 0,
          done: false,
        },
      ],
    },
  ],
};

export default function App() {
  const [workoutState, setWorkoutState] = useState(workout);

  const handleOnChange = (e, exIndex, setIndex) => {
    const value = e.target.value ? parseFloat(e.target.value) : "";
    const exercises = [...workoutState.exercises];

    exercises[exIndex].sets[setIndex] = {
      ...exercises[exIndex].sets[setIndex],
      [e.target.name]: value,
    };
    setWorkoutState({
      ...workoutState,
      exercises,
    });
  };

  return (
    <div className="App">
      <h1>{workoutState.name}</h1>
      {workoutState.exercises.map((ex, exIndex) => (
        <>
          <h2>{ex.name}</h2>
          {ex.sets.map((set, setIndex) => (
            <div key={`${ex.name}_${setIndex}`}>
              <div>
                reps:{" "}
                <input
                  value={set.reps}
                  name="reps"
                  type="number"
                  onChange={(e) => handleOnChange(e, exIndex, setIndex)}
                />
              </div>
              <div>
                weight:{" "}
                <input
                  value={set.weight}
                  name="weight"
                  type="number"
                  onChange={(e) => handleOnChange(e, exIndex, setIndex)}
                />
              </div>
            </div>
          ))}
        </>
      ))}
    </div>
  );
}


Solution

  • I would say you are on the right track with how you want to update the nested state, but you do have a state mutation in your current handleOnChange implementation. You are mutating exercises[exIndex].

    const handleOnChange = (e, exIndex, setIndex) => {
      const value = e.target.value ? parseFloat(e.target.value) : "";
      const exercises = [...workoutState.exercises];
    
      exercises[exIndex].sets[setIndex] = { // <-- mutates exercises[exIndex]
        ...exercises[exIndex].sets[setIndex],
        [e.target.name]: value,
      };
      setWorkoutState({
        ...workoutState,
        exercises,
      });
    };
    

    You should shallow copy each nested level of state you intend to update.

    I recommend using functional state updates so if for any reason more than a single state update is enqueued during a render cycle the update will work from the previous update versus from using the state from the render cycle the update was enqueued.

    I also prefer making the handler a curried function. It takes the indices as arguments and returns a function that consumes the onChange event object. This allows you to remove the anonymous function declaration and need to proxy the event object yourself when attaching the handler, i.e. onChange={handleOnChange(exIndex, setIndex)} versus onChange={e => handleOnChange(e, exIndex, setIndex)}

    const handleOnChange = (eIndex, sIndex) => e => {
      const { name, value } = e.target;
    
      setWorkoutState((workoutState) => ({
        ...workoutState,
        exercises: workoutState.exercises.map((exercise, exerciseIndex) =>
          exerciseIndex === eIndex
            ? {
                ...exercise,
                sets: exercise.sets.map((set, setIndex) =>
                  setIndex === sIndex
                    ? {
                        ...set,
                        [name]: Number(value)
                      }
                    : set
                )
              }
            : exercise
        )
      }));
    };
    

    The inputs. I added a step attribute and attach the curried handler. I also placed each input in a label element for accessibility; you can click the label and focus the field.

    <div>
      <label>
        reps:{" "}
        <input
          value={set.reps}
          name="reps"
          step={1}
          type="number"
          onChange={handleOnChange(exIndex, setIndex)}
        />
      </label>
    </div>
    <div>
      <label>
        weight:{" "}
        <input
          value={set.weight}
          name="weight"
          step={0.1}
          type="number"
          onChange={handleOnChange(exIndex, setIndex)}
        />
      </label>
    </div>
    

    Edit react-change-deeply-nested-state-optimisation

    Full code:

    const workout = {
      id: "123-234sdf-1213",
      name: "wo name",
      done: false,
      exercises: [
        {
          name: "back squat",
          sets: [
            {
              number: 0,
              reps: 0,
              weight: 0,
              done: false
            },
            {
              number: 1,
              reps: 0,
              weight: 0,
              done: false
            },
            {
              number: 2,
              reps: 0,
              weight: 0,
              done: false
            }
          ]
        },
        {
          name: "leg press",
          sets: [
            {
              number: 0,
              reps: 0,
              weight: 0,
              done: false
            },
            {
              number: 1,
              reps: 0,
              weight: 0,
              done: false
            },
            {
              number: 2,
              reps: 0,
              weight: 0,
              done: false
            }
          ]
        }
      ]
    };
    
    export default function App() {
      const [workoutState, setWorkoutState] = useState(workout);
    
      const handleOnChange = (eIndex, sIndex) => (e) => {
        const { name, value } = e.target;
    
        setWorkoutState((workoutState) => ({
          ...workoutState,
          exercises: workoutState.exercises.map((exercise, exerciseIndex) =>
            exerciseIndex === eIndex
              ? {
                  ...exercise,
                  sets: exercise.sets.map((set, setIndex) =>
                    setIndex === sIndex
                      ? {
                          ...set,
                          [name]: Number(value)
                        }
                      : set
                  )
                }
              : exercise
          )
        }));
      };
    
      return (
        <div className="App">
          <h1>{workoutState.name}</h1>
          {workoutState.exercises.map((ex, exIndex) => (
            <>
              <h2>{ex.name}</h2>
              {ex.sets.map((set, setIndex) => (
                <div key={`${ex.name}_${setIndex}`}>
                  <h3>Set {setIndex}</h3>
        <div>
          <label>
            reps:{" "}
            <input
              value={set.reps}
              name="reps"
              step={1}
              type="number"
              onChange={handleOnChange(exIndex, setIndex)}
            />
          </label>
        </div>
        <div>
          <label>
            weight:{" "}
            <input
              value={set.weight}
              name="weight"
              step={0.1}
              type="number"
              onChange={handleOnChange(exIndex, setIndex)}
            />
          </label>
        </div>
                </div>
              ))}
            </>
          ))}
        </div>
      );
    }