Search code examples
javascriptreactjstypescriptmappingtypeerror

Typescript ReactJS: How do you add a value to a mapped array that is nested inside an object?


I am trying to map the boxes property inside my stress state, with each boolean value inside it being represented by an SVG of a square. It correctly displays the initial set of boolean values when the web page first renders, but whenever I try to add a new square using the +block button, the screen displays: TypeError: key.boxes is undefined. I know both key and boxes have types, so I don't understand why I am getting this error. How would I prevent this error from occurring so I can add a new boolean value to the boxes property using the +block button?

import { useState } from "react";

interface Stress {
    label: string
    boxes: boolean[]
}

const Stress: React.FC = () => {
    const [stress, setStress] = useState<Array<any>>([{
            label: "",
            boxes: [false]
        }]);
    const [edit, isEdit] = useState<boolean>(false);

    return (
        <div className="characterSheetBox">
            <h1>STRESS</h1>
            <button className="characterSheetButton" onClick={() => setStress([...stress, { label: "", boxes: [false] }])}>+blocks</button>
            <button className="characterSheetButton" onClick={() => isEdit(!edit)}>Edit</button>
            <div>
                {edit ?
                    stress.map((key: Stress) => (
                        <div>
                            <input type="text" value={ key.label } />
                            {key.boxes.map((box: boolean) => (
                                <div>
                                    <svg>
                                        <rect className="stress" style={{ fill: box ? "red" : "white" }} height={25} width={25} onClick={() => setStress(!box)} />
                                    </svg>
                                </div>
                            ))}
                            <button onClick={() => setStress([...key.boxes, false])}>+block</button>
                        </div>
                    ))
                :
                    stress.map(key => (
                        <div>
                            <p>{ key.label }</p>
                            { key.boxes }
                        </div>
                    ))
                }
            </div>
        </div>
    )
}

export default Stress;

Solution

  • there's a few things that could be improved in the code that will make it easier to see the issue you're referring to, maybe best to go through it bit by bit.

    interface Stress {
      label: string;
      boxes: boolean[];
    }
    
    const [stress, setStress] = useState<Array<any>>([
      {
        label: '',
        boxes: [false],
      },
    ]);
    

    Here you've declared a type for Stress so you can use it for the state which should be an array of Stress. So instead of Array<any> you can make it Array<Stress> or even readonly Stress[] since props and state are to be treated as readonly in react. You probably also want to change the name because stress as array of stress is just gonna be confusing. So say you end up with something like

      const [stressData, setStressData] = useState<readonly Stress[]>([
        {
          label: '',
          boxes: [false],
        },
      ]);
    

    Then this already highlights existing issues you had when setting state further down in the code.

    Then edit and isEdit are confusing names, second member returned from useState is a setter so isEditing and setEditing are clearer.

    Within the code that maps the data to jsx elements, and calls to setStressData target the entire array of data. So any update needs to consider the entirety of the state.

    <svg>
      <rect
        className="stress"
        style={{ fill: box ? 'red' : 'white' }}
        height={25}
        width={25}
        onClick={() => setStress(!box)}
      />
    </svg>
    

    In this case, if you call setStress with !box you've lost the data. Instead of having a state with an array of Stress objects you have state with a single boolean value. So whatever argument you pass to setStress must be of type Stress[]. So in this case you want to toggle the value for the current box in the current stress, so you need to map over the current state updating the values at that index. So the flow is this

    1. Get current state
    2. Map over stress items
    3. If item is not current item return it as it
    4. If item is current map over boxes
    5. If box is current box apply any updates and return updated

    So in code it looks like

    setStressData(
      (
        current //using setState callback form can get current as arg
      ) =>
        //map over current stress state
        current.map((stress, localStressIndex) => {
          //if same index as that being rendered
          //stressIndex here is argument provided when mapping over data in jsx
          if (localStressIndex === stressIndex) {
            //if same return updated
            return {
              //spread existing values
              ...stress,
              //map over box values
              boxes: stress.boxes.map((box, localBoxIndex) => {
                //if box index same as current in render
                if (localBoxIndex === boxIndex) {
                  //return updated value
                  return !box;
                }
                //return as is
                return box;
              }),
            };
          }
          //return as is
          return stress;
        })
    );
    

    It seams like a lot but it can be condensed a lot with ternary expressions etc.. But this is almost like a core thing for data management in react. If you split components up into smaller components also gets more organized.

    So it's a similar thing for the part

    <button onClick={() => setStress([...key.boxes, false])}>
    

    You need to return an array of stress, here the issue is you're passing key.boxes which is boolean[] for current stress. You need to do the same, so ends up being

    <button
      onClick={() =>
        setStressData((current) =>
          current.map((stress, index) =>
            index === stressIndex
              ? { ...stress, boxes: [...stress.boxes, false] }
              : stress
          )
        )
      }
    >
    

    So that's similar to what we did above to map over the box values but a bit simpler as it's just adding a box rather than mapping over boxes as well.

    For both these cases we used index which can work it just means these can't be reordered in the array. Eventually you can use some id defined on stress object.

    Here's a playground link with example https://stackblitz.com/edit/stackblitz-starters-udz66w?file=src%2FApp.tsx

    Hope it helps