1
votes

I'm trying to learn React (with redux) so i'm making an app where i'm able to create workout plans, add workouts to them and then add exercises to a workout.

PlanListComponent

import { Button, Card, Typography } from "@material-ui/core/";
import { makeStyles } from "@material-ui/core/styles";
import DeleteIcon from "@material-ui/icons/Delete";
import React, { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { deletePlan, getPlans } from "../actions/plansActions";
import AddWorkouts from "./AddWorkouts";

const useStyles = makeStyles((theme) => ({}));

function PlansList() {
  const classes = useStyles();

  const { plans } = useSelector((state) => state.plans);
  const dispatch = useDispatch();

  useEffect(() => {
    dispatch(getPlans());
  }, [dispatch]);

  return (
    <div>
      {plans.map((plan) => (
        <Card key={plan._id}>
          <Typography>{plan.name}</Typography>
          <Typography>{plan._id}</Typography>
          <div>
            {plan.workouts.map((workout) => (
              <li key={workout._id}>{workout.description}</li>
            ))}
          </div>
          <AddWorkouts plan={plan} />
          <Button onClick={() => dispatch(deletePlan(plan._id))}>
            <DeleteIcon /> Delete
          </Button>
        </Card>
      ))}
    </div>
  );
}

export default PlansList;

My PlansList component renders a card for every plan. Within this card it renders a list of workouts for every workout within that plan. After adding a workout to a plan, the PlansList component does not rerender. The added workout only shows after i refresh the page. I am guesing this happens because i have to update the state of the nested workout array in order to make React rerender my component.

These are my actions and reducers for adding a workout to a plan. The payload i am sending in my action is an array of objects.

Action

export const addWorkouts = (workouts, planId) => (dispatch, getState) => {
  axios
    .post(`/workouts/${planId}`, workouts, tokenConfig(getState))
    .then(res =>
      dispatch({
        type: ADD_WORKOUTS,
        id: planId
        payload: res.data
      }));
}

Reducer

const initialState = {
  plans: [{
    workouts: []
  }],
  isLoading: false,
};

export default function (state = initialState, action) {
  switch (action.type) {
    case ADD_WORKOUTS:
      return {
        ...state,
        plans: {
          // guessing i should find the right plan by id here
          ...state.plans,
          workouts: {
            ...state.plans.workouts,
            workouts: state.plans.workouts.concat(action.payload)
          }
        }
      };
    default:
      return state;
  }

I've seen a lot of tutorials on how to update the state of nested arrays and tried a few different things, but i can't seem to find the right solution here.

Does anyone has any idea how to fix this issue?

1

1 Answers

1
votes

Your reducer is not updating the right things. You're actually converting the property workouts which was an array into an object which has a property workouts which is an array. It's not what you want to do at all.

Finding and updating an array element in redux is not work the bother when it's easy to have plans be an object which is keyed by the plan id. Does the order of the plans matter? If it does, I would still keep the plans keyed by id, but I would have a separate value in the state which stores an ordered array of plan ids.

What I am suggesting looks like this:

const initialState = {
  plans: {}, // you could call this plansById
  isLoading: false,
  planOrder: [] // totally optional and only needed if the order matters when selecting all plans
};

export default function (state = initialState, action) {
  switch (action.type) {
    case ADD_WORKOUTS:
      const existingPlan = state.plans[action.id] || {};
      return {
        ...state,
        plans: {
          ...state.plans,
          [action.id]: {
            ...existingPlan,
            workouts: ( existingPlan.workouts || [] ).concat(action.payload)
          }
        }
      };
    default:
      return state;
  }

You can break the reducer up into smaller pieces. If you have multiple actions which update a plan, you can make a reducer which handles an individual plan so that you don't have to repeat all of the ... stuff more than once.

function planReducer (planState = { workouts: []}, action ) {
  switch (action.type) {
    case ADD_WORKOUTS:
      return {
        ...planState,
        workouts: planState.workouts.concat(action.payload)
      };
      default:
        return planState;
    }
}

function rootReducer (state = initialState, action) {
  /**
   * you can use a `switch` statement and list all actions which modify an individual plan
   * or use an `if` statement and check for something, like if the action has a `planId` property 
   */
  switch (action.type) {
    case ADD_WORKOUTS:
    case OTHER_ACTION:
      return {
        ...state,
        plans: {
          ...state.plans,
          [action.id]: planReducer(state.plans[action.id], action),
        }
      };
    default:
      return state;
  }
}

With the suggested reducers, you don't need to make any changes to your action, but you do need to change your selector because plans is no longer an array. Your selector becomes (state) => Object.values(state.plans).

If you stored a specific plan order, you would select the order and map from the order to the individual plans: (state) => state.planOrder.map( id => state.plans[id] ).