React JS – Change parent state on child click, map not iterable

I am new to React and dev in general, but I am struggling to figure out how to achieve what I am trying to do. I feel as though I may have missed something along the way.

My goal is to have a list of items, that which on clicked individually, will toggle the visibility of their information.

The problem is that I am not able to map over the state in the parent element to display each object. But the state is in an array so I don’t understand why it wouldn’t be iterable. I do not get this problem when its just an object that I pass props to the child without state.

Is this the correct way to go about doing this? Am I supposed to create another array just to map over my object? I’ve also been a little confused as some sources create a class and use the constructor and render function. Is that deprecated or should I be doing it this way?

Parent

import React from "react";
import { useState } from "react";
//Components
import Card from "./Card";

const CardStack = () => {
  const [habits, setHabits] = [
    {
      id: 1,
      merit: "good",
      title: "Good Habit",
      count: 4,
      text: "Words to be hidden",
      visible: false,
    },
    {
      id: 2,
      merit: "bad",
      title: "Bad Habit",
      count: 1,
      text: "Words to be hidden",
      visible: false,
    },
    {
      id: 3,
      merit: "good",
      title: "Good Habit",
      count: 6,
      text: "Words to be hidden",
      visible: true,
    },
  ];

  const toggleCard = () => {
    this.setHabits((habit) => {
      habit.visible = !visible;
    });
  };

  return (
    <div className="card-stack">
      {habits.map((habit) => (
        <Card habit={habit} key={habit.id} onClick={toggleCard} />
      ))}
    </div>
  );
};

export default CardStack;

Child

import React from "react";

//Components
import Button from "./Button";

const Cards = ({ habit, onClick }) => {
  return (
    <div className="card" key={habit.id} onClick={onClick}>
      <h4 className="title" merit={habit.merit}>
        {habit.title}
        <div className="btn-group">
          <Button className="button" />
          <span className="count">{habit.count}</span>
          <Button className="button" />
        </div>
        {habit.visible ? (
          <div className="content">
            <p>visible</p>
          </div>
        ) : null}
      </h4>
    </div>
  );
};

export default Cards;

Answer

There are a number of problems with your code.

The first has been pointed out by @talfreds in their answer – you need to call useState() to initialize the state variable and its corresponding setter.

const CardStack = () => {
  const [habits, setHabits] = useState([
    {
      id: 1,
      merit: "good",
      title: "Good Habit",
      count: 4,
      text: "Words to be hidden",
      visible: false,
    },
    ...]);

Just doing this should allow your component to render.

But once you click the button, your current toggle handler will overwrite the array stored in habits with a boolean.

To fix this you need to understand that the callback you pass to setState is passed the current value of the relevant state variable for you to work with, and the state will be set to the value that you return from the callback. When working with arrays you need to avoid directly mutating this passed value, in this example by using map() which returns a new array, and by cloning the ‘habit’ object that we are changing use spread syntax.

const toggleCard = (id) => { // pass the id of the 'habit' to toggle
    setHabits((habits) => { // the current 'habits' array is passed to the callback
      // return a new array and avoid mutating nested objects when updating it
      return habits.map((habit) => habit.id === id ? { ...habit, visible: !habit.visible } : habit);
    });
  };


// usage
{habits.map((habit) => (
  ...
  <button type="button" onClick={() => toggleCard(habit.id)}>Toggle</button>
  ...
)}

The last glaring problem is your use of this which is necessary when working with a class based component, but isn’t necessary in a function component and actually won’t work at all in the context of an arrow function.

Here is a shortened example snippet that may help you working through these ideas.

const { useEffect, useState } = React;

const App = () => {
  const [ habits, setHabits ] = useState([ // call useState to initialize 'habits' state
    {
      id: 1,
      merit: 'good',
      title: 'Good Habit',
      count: 4,
      text: 'Words to be hidden',
      visible: false,
    },
    {
      id: 2,
      merit: 'bad',
      title: 'Bad Habit',
      count: 1,
      text: 'Words to be hidden',
      visible: false,
    },
    {
      id: 3,
      merit: 'good',
      title: 'Good Habit',
      count: 6,
      text: 'Words to be hidden',
      visible: true,
    },
  ]);
  
  useEffect(() => {
    console.log('This: ', this);
  }, []);

  const toggleCard = (id) => { // id passed from mapped buttons
    setHabits((habits) => { // the current 'habits' array is passed to the callback
      // return a new array and avoid mutating nested objects when updating it
      return habits.map((habit) => habit.id === id ? { ...habit, visible: !habit.visible } : habit);
    });
  };

  return (
    <div className="card-stack">
      {habits.map((habit) => (
        <div key={habit.id} className="card">
          <h3>{habit.title}</h3>
          {habit.visible
            ? (<p>{habit.text}</p>)
            : null}
          <button type="button" onClick={() => toggleCard(habit.id)}>Toggle</button>
        </div>
      ))}
    </div>
  );
};

ReactDOM.render(
  <App />,
  document.getElementById('root')
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.4/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.4/umd/react-dom.production.min.js"></script>

<div id="root"></div>