0

I've got a custom hook using useReducer that's controlling which components are rendered on a dashboard. It works as expected in the parent, but when I'm using it in a child, useReducer runs, state is changed, but it's not changing in parent component, so it's not re-rendering with the appropriate changes. I'm using the spread operator in my reducer to return a new object. I've tried several hacky things with extra useStates and useEffects inside the hook but none of them have had an impact. I've tried different levels of destructuring, also no effect. I can see the state being changed but it seems like it's not being recognized as a new object when being returned inside the parent object.

Custom Hook

import { useReducer } from "react"

let dashboard = {}

export const dashboardReducer = (state, action) => {
    console.log("dashboardReducer: state, action=", state, action)
    switch (action.component) {
        case 'lowerNav':
            switch (action.label) {
                case 'journal':
                return { ...state, lowerNav: 'journal'}
                case 'progress':
                return { ...state, lowerNav: 'progress'}
                case 'badges':
                return { ...state, lowerNav: 'badges'}
                case 'challenges':
                return { ...state, lowerNav: 'challenges'}
                case 'searchResults':
                return { ...state, lowerNav: 'searchResults'}
            }
        case 'foodSearchBox' :
            if (action.searchResults) {
                return { ...state, searchResults: action.searchResults, lowerNav: "searchResults"}
            } else {
                return { ...state, searchResults: "NO SEARCH RESULTS"}                
            }
        default:
            throw new Error()
    }
}

export const useDashboard = () => {
    const [ state, dispatch ] = useReducer(dashboardReducer, dashboard)
    //fires on every dispatch no matter which component
    console.log("useDashboard: state=", state)      
    return [ state, dispatch ]
}

export const initDashboard = initialState => {
    dashboard = initialState
}

Parent Component

const Dashboard = () => {
  initDashboard({ lowerNav: "journal", log: "daily", meal: "breakfast" });
  const [ state, dispatch ] = useDashboard();
  const lowerNav = state.lowerNav

  useEffect(() => {
    console.log("Dashboard: useEffect: state=", state) //Fires when a dispatch is run from this component, but not from any other components
  }, [state])

  const dateOptions = { year: "numeric", month: "long", day: "numeric" }; 
  const currentDate = new Date(Date.now()); 

  return (
    <Layout>
      <div className="flex">
        <DashUser />
        <div className="flex-1"></div>
        <div className="flex-1 px-32 self-center">
          <FoodSearchBox />
        </div>
      </div>
      <nav className="flex bg-mobileFoot">
        <div className="flex-1"></div>
        <ul className="flex-1 flex justify-around text-lg font-medium py-2">
          <li
            className={`${
              lowerNav === "journal" ? "border-b-2 border-pink-500" : ""
            } cursor-pointer`}
            value="journal"
            onClick={() =>
              dispatch({ component: "lowerNav", label: "journal" })
            }
          >
            Food Journal
          </li>
          <li
            className={`${
              lowerNav === "progress" ? "border-b-2 border-pink-500" : ""
            } cursor-pointer`}
            value="progress"
            onClick={() =>
              dispatch({ component: "lowerNav", label: "progress" })
            }
          >
            Progress
          </li>
          <li
            className={`${
              lowerNav === "badges" ? "border-b-2 border-pink-500" : ""
            } cursor-pointer`}
            value="badges"
            onClick={() => dispatch({ component: "lowerNav", label: "badges" })}
          >
            Badges
          </li>
          <li
            className={`${
              lowerNav === "challenges" ? "border-b-2 border-pink-500" : ""
            } cursor-pointer`}
            value="challenges"
            onClick={() =>
              dispatch({ component: "lowerNav", label: "challenges" })
            }
          >
            Challenges
          </li>
        </ul>
        <span className="flex flex-1 text-sm justify-end items-center">
          <time className="pr-32">
            {currentDate.toLocaleString("en-US", dateOptions)}
          </time>
        </span>
      </nav>
      <div className="flex py-4">
        <DailyVibe />         
        <div className="flex-1"></div>
        <div className="border border-black mr-32 ml-6">Macro Charts</div>
      </div>
      <div className="ml-20 mr-32">
        {lowerNav === "journal" ? (
          <DesktopFoodJournal />
        ) : lowerNav === "progress" ? (
          <Progress />
        ) : lowerNav === "badges" ? (
          "Badges"
        ) : lowerNav === "challenges" ? (
          "Challenges"
        ) : lowerNav === "searchResults" ? (
          <FoodSearchResults />
        ) : (
          "Error"
        )}
      </div>
    </Layout>
  );
}

export default withApollo(Dashboard)

Child component

import { useState } from "react";
import { foodDbSearch } from "../../lib/edamam.js";
import { useDashboard } from "../../lib/hooks/useDashboard.js";

export default function FoodSearchBox() {
  const [item, setItem] = useState("");
  const dispatch = useDashboard()[1];

  const handleChange = e => {
    setItem(e.target.value);
  };

  const query = item.replace(" ", "%20"); //  Format the entered food item for the API call

  const handleSubmit = async e => {
    e.preventDefault();
    const list = await foodDbSearch(query); //  Hit the foodDB API
    //  Add the search results to dashboard state 
    dispatch({ component: "foodSearchBox", searchResults: list.hints }); 
    setItem('')  //  Reset input
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        className="w-full border border-purple-400 rounded focus:border-purple-200 px-4 py-2"
        type="text"
        placeholder="Search Food Item"
        name="food"
        value={item}
        onChange={handleChange}
      />
    </form>
  );
}

I'm trying to refactor from what was originally a lot of messy prop drilling with useState hooks being passed around all over the place. I'm using Next.js and I'm trying to stay away from using the Context API or bringing in Redux. I really only need to persist state on individual page components, and it's really just local UI state, as I'm handling most of the data with apollo-hooks.

1 Answers1

3

The dispatch function that you call in the child component is not the same function as the one in the parent component, and it doesn't update the same state. Different usages of the useDashboard hook return different (state, dispatch) pairs, and they won't affect each other.

If you want the parent component's state to be changeable from the child component, but you don't want to use the context API, you have to pass the parent component's dispatch function (or a callback that uses it) to the child component as a prop.

const Parent = () => {
  const [state, dispatch] = useDashboard();

  return (
    <Child
      updateFoodSearch={(listItems) =>
        dispatch({ component: "foodSearchBox", listItems })
      }
    />
  );
};
backtick
  • 2,685
  • 10
  • 18
  • 3
    Just want to add on from the react docs that [useReducer](https://reactjs.org/docs/hooks-reference.html#usereducer) has a note that "React guarantees that `dispatch` function identity is stable and won’t change on re-renders." It's totally safe to pass to children. – Drew Reese Mar 28 '20 at 05:14
  • Understood. I was under the impression (mistakenly, apparently) that if I declared the initial state outside of the hook and initialized it on the parent component that I would then be accessing that same state instance when using the hook in other components, since even though the hook is a new instance it would be passed the global state object. I was assuming that if the parent hadn't re-rendered, then the dashboard state object being passed to the hook would still exist and that's what the dispatch from the child would be replacing. How far off base am I? – DoubleBridges Mar 28 '20 at 05:24
  • Wait, it's not the same useReducer instance so the dispatch in one is not going to change the return value in the other. Which is what you said originally. Thanks for your help. – DoubleBridges Mar 28 '20 at 05:30
  • @DrewReese I will definitely keep that in mind. Some of the other involved children components are deeply nested, which is why my first implementation was such a mess. So it looks like I'll be going with the context API. – DoubleBridges Mar 28 '20 at 06:00
  • @DoubleBridges The `context` API is what redux uses under the hood BTW, and this solves for the issue of "prop drilling" you'd mentioned. If the children are more than a couple generations deep then it's a no brainer to setup basic `provider/consumer`. – Drew Reese Mar 28 '20 at 06:06