How do you use refs to access the values of mapped children in React?
Asked Answered
H

1

7

I have an app that pulls data from a GraphQL database and then .maps it into custom form components (quantity number textboxes). Right now, the components themselves are holding state of their individual quantities, but I need to be able to access the values from the Parent so I can change the amounts using inputs elsewhere in the app. I've looked around at how this is done and I think this might be what I need, but I don't know how to apply it: [How to target DOM with React useRef in map][1]

My app is made up of a parent element with a Top Bar containing an input, a Modal component, and a map of elements populated from the GraphQL query.

export default function Home() {
  const [batch, setBatch] = useState([]);
  const [target, setTarget] = useState("");
  const [batchCount, setBatchCount] = useState(0);
  const [cartModalStatus, setCartModalStatus] = useState(false);

  const elementValues = useRef([]);
  const fetcher = query => request("https://data.objkt.com/v2/graphql", query);
  const { data, error } = useSWR(
  `{
    listing(where: {token: {creators: {creator_address: {_eq: ` + target + `}}, supply: {_gt: "0"}}, status: {_eq: "active"}}, order_by: {token: {timestamp: asc}, price: asc}) {
      token {
        name
        display_uri
        timestamp
        supply
      }
      price
      seller {
        address
        alias
      }
      amount_left
    }
  }`, fetcher);

  const handleItemCount = () => {
    let count = 0;

    for (let i = 0; i < batch.length; i++)
      count += batch[i][1];

    setBatchCount(count);
  }

  const onCartClick = () => {
    setCartModalStatus(true);
  }

  const onHideModal = () => {
    setCartModalStatus(false);
  }

  const onSubmit = (e) => {
    console.log(e);
    setTarget(e.target[0].value);
    e.preventDefault();
  };

  const onChange = (el, quantity) => {
    let batchCopy = batch;
    let found = false;
    let foundIndex;

    for (var i = 0; i < batchCopy.length; i++)
      if (batchCopy[i][0] === el)
      {
        found = true;
        foundIndex = i;
      }       
    
    if (!found) batchCopy.push([el, quantity]);
    else if (found) batchCopy[foundIndex][1] = quantity

    setBatch(batchCopy);
    handleItemCount();

  };

  return (
    <Container>
      <TopBar onSubmit={onSubmit} cartTotal={batchCount} onCartClick={onCartClick}/>
      <CartModal show={cartModalStatus} onHideModal={onHideModal} batch={batch}/>
      <DataMap target={target} onChange={onChange} data={data} error={error}/>
    </Container>
  )
}

The DataMap is the data from the query. I need to match each element to a quantity, which I've done by keeping individual state in each child element, but I need the parent to have access to the quantity.

export function DataMap(props){
  
  const onChange = (el, quantity) => {
    console.dir(el);
    props.onChange(el, quantity);
  };

  if (props.target === "") return <div>No target.</div>;
  if (props.target !== "" && validateAddress(props.target) !== 3) return <div>Invalid address.</div>;
  if (props.error) {
    console.log(props.error);
    return <div>Failed to Load</div>;
  }
  if (!props.data) return <div>Loading...</div>;
  if (!props.error && props.data){

    return <Row>
    {props.data["listing"]
    .map((el, i , arr) => {
      return (
      <Col key={i} id={i} xs={4} sm={4} md={3} lg={2}>
          <StateImg src={"https://ipfs.io/ipfs/" + el["token"]["display_uri"].slice(7,)}/>
          <h5>{el["token"]["name"]}</h5>
          <p>{el["price"] / 1000000} {" xtz"}</p>
          <Row>
            <QuantityForm remaining={el["amount_left"]} onChange={onChange} element={el}/>
          </Row>
      </Col>)      
    })}
    </Row>
  }
}

Lastly, QuantityForms are just the form input for the quantity of each item. Right now state is kept in each individual element and passed up to the parent's "batch" state, but that means I can't alter the quantities other than using these specific inputs.

export function QuantityForm(props){
  const [quantity, setQuantity] = useState(0);

  useEffect(()=>{
    props.onChange(props.element, quantity); 
  }, [props.element, quantity]);

  const onChange = (e) => {
    setQuantity(parseInt(e.target.value));  
    e.preventDefault();   
  };

  return (
    <Form.Group>
      <Form.Label>Quantity</Form.Label>
      <InputGroup>
        <Form.Control onChange={onChange} onKeyDown={(e)=>{e.preventDefault();}} type={"number"} value={quantity} min={0} max={props.remaining} aria-describedby="basic-addon1"/>
          <InputGroup.Text id="basic-addon1">
            {"/" + props.remaining}
          </InputGroup.Text>
      </InputGroup>
    </Form.Group>
  );
}

Any help in getting access to the values of the mapped QuantityForms using Refs is greatly appreciated. [1]: How target DOM with react useRef in map

Hateful answered 26/8, 2022 at 5:45 Comment(0)
J
4

You don't need refs here. The "React way" is to move the state up to the common parent. So if you want to modify quantities in QuantityForm and in CartModal then you should keep it in the Home component.

Let's use batch for that:

const [batch, setBatch] = useState([]); // [{index, count}]

You don't need a state for batchCount. Just calculate, it's cheap:

const batchCount = batch.reduce((sum, item) => sum + item.count, 0);

Here we update an existing items, insert a new items and remove those with count === 0:

const onChange = (index, count) => {
  if (count === 0) {
    setBatch(batch.filter((b) => b.index !== index));
  } else {
    const found = batch.find((b) => b.index === index);
    if (found) {
      setBatch(batch.map((b) => (b.index === index ? { index, count } : b)));
    } else {
      setBatch([...batch, { index, count }]);
    }
  }
};

Please notice that the following doesn't work in React because Object.is(batch, batchCopy) === true:

let batchCopy = batch;
...
setBatch(batchCopy);

Let's render Home component:

return (
  <div>
    <TopBar cartTotal={batchCount} />
    <DataMap data={data} batch={batch} onChange={onChange} />
    <CartModal data={data} batch={batch} onChange={onChange} />
  </div>
);

data contains all the information about products, it is non-reactive value.

batch contains only quantities and it is reactive value.

const TopBar = ({ cartTotal }) => {
  return (
    <div>
      <h2>TopBar</h2>
      <h3>Cart total: {cartTotal}</h3>
    </div>
  );
};
const DataMap = ({ data, batch, onChange }) => {
  return (
    <div>
      <h2>DataMap</h2>
      {data.map(({ token: { name }, price, amount_left }, index) => (
        <div key={name}>
          <div>name: {name}</div>
          <div>price: {price}</div>
          <QuantityForm
            value={batch.find((b) => b.index === index)?.count || 0}
            maxValue={amount_left}
            onChange={(v) => onChange(index, v)}
          />
        </div>
      ))}
    </div>
  );
};
const QuantityForm = ({ value, maxValue, onChange }) => {
  return (
    <div style={{ display: "flex" }}>
      {value} / {maxValue}
      <button onClick={(e) => onChange(Math.min(value + 1, maxValue))}>
        +
      </button>
      <button onClick={(e) => onChange(Math.max(value - 1, 0))}>-</button>
    </div>
  );
};
const CartModal = ({ data, batch, onChange }) => {
  return (
    <div>
      <h2>CartModel</h2>
      {batch.map(({ index, count }) => (
        <div key={index}>
          {data[index].token.name}: {count}
          <button onClick={(e) => onChange(index, 0)}>Cancel</button>
        </div>
      ))}
    </div>
  );
};

Working example

Josefjosefa answered 30/8, 2022 at 16:37 Comment(1)
Thank you for the work you have done in helping me deal with this problem. All I have to do left is integrate your work into my structure (query stuff on the top bar, etc) and test it. I accepted this answer because it goes so far in answering the question and includes a working proof-of-concept. Thank you, again!Hateful

© 2022 - 2024 — McMap. All rights reserved.