Page re-renders on global state update (using Zustand.js)

Hey everyone,

I am running into a page re-render issue every time I am adding or removing a new item to the global state, for which I am using Zustand.js. This shouldn’t happen, obviously. Everything else is working fine, the cookies are set properly and every time I am coming back to the page, it puts the cookie data into the global state.

I am a bit lost here now, as AI tools are just letting me run in circles and old school googling hasn’t helped me as well. Would be great if someone could have a look into this, and let me know what I need to improve, so the re-render does not happen. I am very helpful for all suggestions and also improvements. Just let me know if you need any further clarification on some things, I tried to include everything as good as I can.

Tech used:

  • Next.js 15.2.4
  • React 19.0
  • Zustand 5.0.1

Page structure

Page component loading cookieDate through server action:

export default async function page() {
  const cookieData = await readCookie();

  return (
      <Tourplaner cookieData={cookieData} />
  );
}

Tourplaner component passing the props to the Map component and wrapping it in the provider, where the global state should be used:

"use client";
export default function Tourplaner({ cookieData }: TourplanerProps) {
  return (
      <CoordinateStoreProvider>
        <Map cookieData={cookieData} />
      </CoordinateStoreProvider>
    </div>
  );
}

Map component which holds each tour component that gets displayed depending on which region is chosen. Here, the cookieData gets passed into the global state to set the previous session data:

"use client";
export default function Map({ cookieData }: TourplanerMapProps) {

  // setting up the cookieData
  const setCoordinates = useCoordinateStore((state) => state.setCoordinates);

  useEffect(() => {
    if (cookieData) {
      setCoordinates(cookieData); // one-time update
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <>
      {showRegion > 0 && (
        <div className="tours-container">
          {tourData
            .filter((tour) => tour.region[0] === showRegion)
            .map((tour) => (
              <Tour key={uuidv4()} tourInfo={tour} />
            ))}
        </div>
      )}
    </>
  );
}

The tour components hold the CoordinateManager which is handling the adding and removal of the global state:

"use client";
function Tour({ tourInfo }: TourProps) {

  return (
    <div className="tour-container">
      <div className="tour-info">
        <CoordinateManager
          destination={{
            id: tourInfo.id,
            title: tourInfo.title.rendered,
          }}
        />
      </div>
    </div>
  );
}

export default memo(Tour, (prevProps, nextProps) => {
  return prevProps.tourInfo.id === nextProps.tourInfo.id;
});

Here the state will receive input to add or remove the data provided depending on it if the tour coordinates already exist in state.

"use client";

export default function CoordinateManager({ destination }: CoordinateProps) {
  // Sync local state with store state
  const coordinates = useCoordinateStore((state: { coordinates: Coordinate[] }) => state.coordinates);
  const addCoordinate = useCoordinateStore(
    (state: { addCoordinate: (coord: Coordinate) => void }) => state.addCoordinate
  );
  const removeCoordinate = useCoordinateStore(
    (state: { removeCoordinate: (id: number) => void }) => state.removeCoordinate
  );

  const coordExists = coordinates.some((coord) => coord.id === destination.id);

  const handleAdd = () => {
    if (!coordExists) {
      const newCoordinate = {
        id: destination.id,
        title: destination.title,
      };
      addCoordinate(newCoordinate);
      console.log("CoordinateManager rendered");
    } else {
      console.log("Coordinate already exists in local state:", destination.id); // Log duplicate attempt
    }
  };

  const handleRemove = () => {
    if (coordExists) {
      removeCoordinate(destination.id);
    } else {
      console.log("Coordinate does not exist in local state:", destination.id); // Log missing attempt
    }
  };

  return (
    <div className="tour-interaction">
      <button onClick={!coordExists ? handleAdd : handleRemove} className="add-tour btn">
        {!coordExists ? "Add tour" : "Remove tour"}
      </button>
    </div>
  );
}

This is the store component, calling the server action to update the cookie every time a change takes place:

import { createStore } from "zustand/vanilla";

export type CoordinateStore = {
  coordinates: Coordinate[];
  addCoordinate: (newCoordinate: Coordinate) => void;
  removeCoordinate: (id: number) => void;
  setCoordinates: (newCoordinates: Coordinate[]) => void;
};

const defaultState: CoordinateStore = {
  coordinates: [],
  addCoordinate: () => {},
  removeCoordinate: () => {},
  setCoordinates: () => {},
};

export const createCoordinateStore = (initState: CoordinateStore = defaultState) => {
  //server action to set cookie
  async function updateCookie(coordinates: Coordinate[]) {
    await setCookie(coordinates);
  }

  const store = createStore<CoordinateStore>((set) => ({
    ...initState,
    addCoordinate: (newCoordinate) =>
      set((state) => {
        const exists = state.coordinates.some((coord) => coord.id === newCoordinate.id);
        if (exists) return state;

        const newCoordinates = [...state.coordinates, newCoordinate];
        updateCookie(newCoordinates);
        return { coordinates: newCoordinates };
      }),
    removeCoordinate: (id) =>
      set((state) => {
        const newCoordinates = state.coordinates.filter((coord) => coord.id !== id);
        updateCookie(newCoordinates);
        return { coordinates: newCoordinates };
      }),
    setCoordinates: (
      newCoordinates 
    ) =>
      set(() => {
        updateCookie(newCoordinates);
        return { coordinates: newCoordinates };
      }),
  }));

  return store;
};

Last but not least the store provider:

"use client";

import { useStore } from "zustand";

type CoordinateStoreApi = ReturnType<typeof createCoordinateStore>;

const CoordinateStoreContext = createContext<CoordinateStoreApi | undefined>(undefined);

interface CoordinateStoreProviderProps {
  children: ReactNode;
}

export const CoordinateStoreProvider = ({ children }: CoordinateStoreProviderProps) => {
  const storeRef = useRef<CoordinateStoreApi>();
  if (!storeRef.current) {
    storeRef.current = createCoordinateStore();
  }
  return (
    <CoordinateStoreContext.Provider value={storeRef.current}>{children}</CoordinateStoreContext.Provider>
  );
};

export const useCoordinateStore = <T,>(selector: (store: CoordinateStore) => T): T => {
  const store = useContext(CoordinateStoreContext);
  if (!store) {
    throw new Error("useCoordinateStore must be used within a CoordinateStoreProvider");
  }
  return useStore(store, selector);
};

Hey @patrikur

this is definitely an interesting issue, and i’m doing to assume you redacted some things as I see some import/jsx issues.

With that, one thing I noticed is that your ContextProvider isn’t following the recommendation of the zustand docs. There is a chance that maybe that global state is causing the entire tree to rerender?

The idea of Zustand is that the store can be created outside of react, and then use the store though its react “wrapped” methods. In their context example, they just use the store as the context value itself.

Let me know your thoughts, happy to try to dive in more!

Hey @miguelcabs

Thanks for the answer and the input. Very good pointing to the documentation, my approach was a bit over-engineered. The simple way also does it, though it did not solve my problem.

I was able to fix it by switching from using a cookie to just using localStorage for storing the data. As the localStorage is client side, all changes will be handled there and nothing is saved to the server cookie, which caused the page to re-render every time. I also implemented a delayed render with useEffect to wait for the data from the localStorage being loaded into the Context Provider, avoiding Hydration Errors. Now it is working as intended :slight_smile:

1 Like

This topic was automatically closed 7 days after the last reply. New replies are no longer allowed.