Search code examples
javascriptreactjsreact-hooksreact-componentreact-state

ReactJS: State of type array is deconstructing into element during re-render


What I'm trying to do

I have a main component with an array of "profiles". For each profile, I have two different CONDITIONAL components (only one is shown at once). Each of these two components has a button that is supposed to switch the components when clicked. So I have lifted the state up to the main component, created component state using the 'useState' hook (which is an array, where each index is a string representing the child component to be shown for each element in the profiles array), I've created two functions for event handling for these clicks, and passed them into their child components as render props.

They start out as the first child component.

The Problem & How I found it

When you press the button to switch to the other component, it works. When you press the button to go back, it crashes. Saying "TypeError: Cannot assign to read-only property '0' of string 'large'". I put some console.log(state) after the useState initialization, and after the state change calls in each function. What happens (with a test list with just one element) is that

  1. When the component initializes, the state is shown as ['normal'] (original state, good)
  2. When button of first component is clicked, ['normal'] becomes ['large'] (as intended)
  3. When a component is now re-rendered, the state becomes 'large' (no longer an array)
  4. When the button of second component is clicked, the app crashes cause it couldn't change the array element casue it's no longer an array

Main Component

const Peers = props => {
    let dummyPeer = {
        _id: "9asdf98sj3942j4fs9ji",
        user: {
            name: "Test Peer",
            avatar: "//www.gravatar.com/avatar/cd56136f6d9abfdf4a0198dc9ce656c8?s=200&r=pg&d=mm"
        },
        bio: "Biography for Test Peer",
        year: "2022",
        courses: [
            "CISC124",
            "PSYC223",
            "PSYC236",
            "COMM200",
            "CISC251"
        ]
    }

    let profiles = [];
    profiles.push(dummyPeer);

    let initialState = [];
    profiles.forEach(profile => {
        initialState.push("normal");
    });

    let [viewState, setViewState] = useState(initialState);
    console.log(viewState);

    const openLargeView = (id) => {
        let changeIndex = profiles.map(profile => profile._id).indexOf(id);
        setViewState(state => state[changeIndex] = "large");
        console.log(viewState);
    }

    const closeLargeView = (id) => {
        let changeIndex = profiles.map(profile => profile._id).indexOf(id);
        setViewState(state => state[changeIndex] = "normal");
        console.log(viewState);
    }

    return (
        <Fragment>
            {profiles.map((profile, index) => (<Fragment key={profile._id} >
                {viewState[index] === "normal" ? (
                    <Peer openLargeView={openLargeView} profile={profile} />
                ) : (
                    <ViewPeer closeLargeView={closeLargeView} profile={profile} />
                )}
            </Fragment>))}
        </Fragment>
    )
}

Child Component 1:

const Peer = ({ profile, openLargeView }) => {
    const { _id, user, bio, year, courses } = profile;
    const { avatar } = user;

    return (<Fragment>
        <div className="card-row">
            <div className="profile-header">
                <h1 className="peer-text row-title"> {user.name} </h1>
                <p className="peer-text peer-small"> {year} </p>
                <img className="avatar avatar-peer-small" src={avatar} alt='' />
            </div>
            <button onClick={() => openLargeView(_id)} className="btn-small"> More </button>
        </div>
     </Fragment>)
}

Child Component 2:

const ViewPeer = ({ profile, closeLargeView }) => {
    const { _id, user, bio, year, courses } = profile;
    const { avatar } = user;

    let courseElements = courses.map((course, index) =>
    <li key={index} className="profile-text"> {course} </li>
    );

    return (
        <Fragment>
            <div className="card-md peer-card">
                <div className="profile-header">
                    <h1 className="peer-text"> {user.name} </h1>

                    <img className="avatar avatar-peer" src={avatar} alt='' />
                </div>

                <div className="profile-info">
                    <h2 className="profile-text"> {bio} </h2>
                    <h2 className="profile-text2"> Year: {year} </h2>
                    <ul className="course-list"> {courseElements} </ul>

                    <div className="profile-button-group">
                        <button onClick={() => closeLargeView(_id)} className="btn-small"> Close </button>
                        <button className="btn-small"> Send Buddy Request </button>
                    </div>
                </div>
            </div>
            </Fragment>
    )
}

Expected and actual results

I expect it to go back to the original component when the button of the first component is clicked but the state turned into an array to a string and the app crashes.


Solution

  • The problem here is the way viewState is being updated in openLargeView() and closeLargeView().

    When those functions are called, the call to setViewState invokes the state change callback that actually changes the type of the viewState from Array to String:

    /* 
    Summary of problem with following line of code:
    1. The statement: state[changeIndex] = "large" returns the string "large"
    2. When executed, the statement returns the "large" string from the callback
    3. The viewState therefore becomes a string with value "large"
    */
    setViewState(state => state[changeIndex] = "large"); 
    

    Consider revising these state updates to something like this:

    setViewState(state => {
        /* 
        1. Shallow clone state into a new array with ... spread
        2. Assign value of "large" to the "changeIndex" in cloned array
        3. Return cloned array as new state for viewState
        */
        const arrayClone = [...state];
        arrayClone[changeIndex] = "large";
        return arrayClone;
    });
    

    This ensures that the state passed back to your component by the setViewState() callback is of the array type, which is what your component is expecting. A more complete example showing were all changes are needed would be:

    const Peers = props => {
    
        const profiles = [{
            _id: "9asdf98sj3942j4fs9ji",
            user: {
                name: "Test Peer",
                avatar: "//www.gravatar.com/avatar/" + 
                        "cd56136f6d9abfdf4a0198dc9ce656c8?s=200&r=pg&d=mm"
            },
            bio: "Biography for Test Peer",
            year: "2022",
            courses: [
                "CISC124",
                "PSYC223",
                "PSYC236",
                "COMM200",
                "CISC251"
            ]
        }]
    
        let [viewState, setViewState] = useState(["normal"]);
    
        const openLargeView = (id) => {
            let changeIndex = profiles.map(profile => profile._id).indexOf(id);
            setViewState(state => {
                const arrayClone = [...state];
                arrayClone[changeIndex] = "normal";
                return arrayClone;
            });
        }
    
        const closeLargeView = (id) => {
            let changeIndex = profiles.map(profile => profile._id).indexOf(id);
            setViewState(state => {
                const arrayClone = [...state];
                arrayClone[changeIndex] = "large";
                return arrayClone;
            });
        }
    
        return (
            <Fragment>
                {profiles.map((profile, index) => (<Fragment key={profile._id} >
                    {viewState[index] === "normal" ? (
                        <Peer openLargeView={openLargeView} profile={profile} />
                    ) : (
                        <ViewPeer closeLargeView={closeLargeView} profile={profile} />
                    )}
                </Fragment>))}
            </Fragment>
        )
    }
    

    Hope that helps!