How to dynamically use useReducer when looping over a list?

[亡魂溺海] 提交于 2020-05-17 03:01:15

问题


I am trying to show a list of Times (e.g. 07:00, 07:30) but when a repeated time appears, show the number of repetitons by its side (e.g. 07:30, 08:00³)

As I am looping over a list, each item will need its own state so that the counter can be set and displayed next to each Time

At the moment, I am having trouble with too many rerenders, but I am also not sure if my reducer is correct

The code without any comments can be seen in this repo: https://github.com/charles7771/decrease-number-wont-work/blob/master/index.js

const TimeGrid = () => {

  const reducer = (state, action) => {
    switch(action.type) {
      case 'SET_COUNTER':
        return {
          ...state,
          [`counter${action.id}`]: action.payload
        }
        default:
          return state
    }
  }

  //not sure if this bit is correct
  let [{ counter }, dispatchReducer] = useReducer(reducer, {
    counter: '',
  })

My Context import and allBookedTimes

const { theme, timesUnavailable, 
        removeFromTimesUnavailable, 
        addToTimesUnavailable } = useContext(Context)

const allBookedTimes = allBookings.map(element => element.time)
//below, both have been mapped out of a JSON file
const extractedTimesOnly = availableTimesJSON.map(item => item.time)
const availableTimes = availableTimesJSON.map(item => item)

I have useful function to count the number of times a Time is repeated

  //used to count instances. (e.g. 22:30: 3, 23:00: 1)
  const counts = {}
  extractedTimesOnly.forEach(x => {
    counts[x] = (counts[x] || 0) + 1
  })

  //used to not repeat a Time
  const timeAlreadyDisplayed = []

And this is the logic I am using to loop over a list of Times and show each one with their counter by its side, as well as try and reduce the counter with a click.

  const displayAvailableTimes = availableTimes.map((item, index) => {
    //tries to set the value of counter0 (or counter1, ... counterN) 
    //to the number of instances it appears,
    //too many rerenders occurs...
    dispatchReducer({
      type: 'SET_COUNTER',
      id: item.id,
      payload: counts[`${item.time}`] //doesn't seem to be working. tried logging it and it shows nothing
    })

    //counter = counts[`${item.time}`] -----> works, but then I am not doing this through the dispatcher

    //maybe this logic could be flawed too?
    if (index > 0 &&
      item.time === availableTimes[index - 1].time &&
      item.time !== availableTimes[index - 2].time) {
      return (
          //tries to show the counter below
        <span> {counter} </span>
      )
    }
    else if (item.time > currentTime - 10 && !timeAlreadyDisplayed[item.time]) {
      timeAlreadyDisplayed[item.time] = true
      return (
        <li
          key={item.id}
          id={item.id}
          onClick={() => {
            //tries to decrease the counter, I don't think it works
            counter > 1 ? dispatchReducer({
              type: 'SET_COUNTER',
              id: item.id,
              payload: counter - 1
            }) :
              allBookedTimes.includes(item.time) && item.day === 'today'
                ? void 0
                timesUnavailable.includes(item)
                  ? removeFromTimesUnavailable(item)
                  : addToTimesUnavailable(item)
          }}>
          {item.time}
        </li>
      )
    } 
    return null
  })

  return (
    <>
      <ul>{displayAvailableTimes}</ul>
    </>
  )
}

回答1:


I will give you some observations with regards to counting the times and decreasing their values on click. I explain the main problems in your code and provide a differnt approach of implementation that allows you to continue with your business logic.

1. proper access to counts

The forEach loop uses the values of the array as keys for the counts object. It seems that you rather want to use the x.time value, because this is how you later access it (payload: counts[${item.time}]). x itself is an object.

2. proper usage of useReducer

useReducer gives you a state object in the first item of the returned array. You immediately decompose it using { counter }. The value of that counter variable is the initial value (''). Your reducer sets values in the state object with keys in the form of counter${action.id}, so the decomposed counter variable will not change.

I think you want something like this:

const [counters, dispatchReducer] = useReducer(reducer, {}); // not decomposed, the counters variable holds the full state of all counters you add using your `SET_COUNTER` action.

Later, when you try to render your counters you currently do { counter } which is always empty ('') as this still refers to your original inital state. Now with the counters holding the full state you can access the counter of your counters object for the current item by using its id:

    {if (index > 0 &&
      item.time === availableTimes[index - 1].time &&
      item.time !== availableTimes[index - 2].time) {
      return (
        <span> {counters[`counter${item.id}`]} </span>
      )
    }

3. general code structure

There are more issues and the code is pretty crazy and very hard to comprehend (e.g. because of mixing concepts in confusing ways). Even if you fix the said observations, I doubt it will result in something that does what you want or that you ever are able to maintain. So I came up with a different code structure that might give you a new way of thinking on how to implement it.

You do not need useReducer because your state is pretty flat. Reducers are better suited for more complex state, but in the end it still is local component state.

I do not know what exactly you want to achieve when clicking on the items, so I just reduce the count, because I think that is what this question is about.

Here is a codesandbox of the following code in action: https://codesandbox.io/s/relaxed-roentgen-xeqfi?file=/src/App.js

import React, { useCallback, useEffect, useState } from "react";

const availableTimes = [
  { time: "07:30" },
  { time: "08:00" },
  { time: "08:00" },
  { time: "08:00" },
  { time: "09:30" },
  { time: "10:00" }
];

const CounterApp = () => {
  const [counts, setCounts] = useState({});
  useEffect(() => {
    const counts = {};
    availableTimes.forEach(x => {
      counts[x.time] = (counts[x.time] || 0) + 1;
    });
    setCounts(counts);
  }, []);

  const onClick = useCallback(time => {
    // Your logic on what to do on click goes here
    // Fore example, I only reduce the count of the given time.
    setCounts(prev => ({
      ...prev,
      [time]: prev[time] - 1,
    }));
  }, []);

  return (
    <div>
      <h2>Counts:</h2>
      <ul>
        {Object.keys(counts).map(time => (
          <li key={time} onClick={() => onClick(time)}>
            {time} ({counts[time]})
          </li>
        ))}
      </ul>
    </div>
  );
};

export default CounterApp;



回答2:


The way you are setting your state in your reducer does not match with how you are retrieving it. You are also getting too many rerenders because you are calling dispatchReducer several times (once for every element in availableTimes). All the logic in displayAvailableTimes should happen when initializing the state of the reducer.

  const reducer = (state, action) => {
    switch(action.type) {
      case 'SET_COUNTER':
        return {
          ...state,
          [`counter${action.id}`]: action.payload
        }
        default:
          return state
    }
  }

  const counts = {}
  extractedTimesOnly.forEach(x => {
    counts[x] = (counts[x] || 0) + 1
  })

  const init = (initialState) => availableTimes.reduce((accum, item, index) => ({
      ...accum,
      `counter${item.id}`: counts[`${item.time}`]
  }), initialState);

  let [state, dispatchReducer] = useReducer(reducer, {
    counter: '',
  }, init)
const displayAvailableTimes = availableTimes.map((item, index) => {
  if (index > 0 &&
    item.time === availableTimes[index - 1].time &&
    item.time !== availableTimes[index - 2].time) { //An array out of bounds error could happen here, FYI
    return (
      <span> {state[`counter${item.id}`]} </span>
    )
  } else if (item.time > currentTime - 10 && !timeAlreadyDisplayed[item.time]) {
    timeAlreadyDisplayed[item.time] = true
      return (
        <li
          key={item.id}
          id={item.id}
          onClick={() => {
            state[`counter${item.id}`] > 1 ? dispatchReducer({
              type: 'SET_COUNTER',
              id: item.id,
              payload: state[`counter${item.id}`] - 1
            }) :
              allBookedTimes.includes(item.time) && item.day === 'today'
                ? void 0  //did you miss a colon here?
                timesUnavailable.includes(item)
                  ? removeFromTimesUnavailable(item)
                  : addToTimesUnavailable(item)
          }}>
          {item.time}
        </li>
      )
    } 
});

This will fix the problems you are facing right now. However, you really do not need a reducer if this is all you are using it for. Do refer to the answer by Stuck to see how you should better structure it so it is more readable and maintainable.



来源:https://stackoverflow.com/questions/61255139/how-to-dynamically-use-usereducer-when-looping-over-a-list

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!