ProNextJS
    State Management with Next.js App RouterLevel up with State Management with Next.js App Router

    I respect your privacy. Unsubscribe at any time.

    This is a free tutorial

    In exchange for your email address, you'll get full access to this and other free ProNextJS tutorials.

    Why? First and foremost, your inbox allows us to directly communicate about the latest ProNextJS material. This includes free tutorials, NextJS Tips, and periodic update about trends, tools, and NextJS happenings that I'm excited about.

    In addition to the piles of free NextJS content, you'll get the earliest access and best discounts to the paid courses when they launch.

    There won't be any spam, and every email you get will have an unsubscribe link.

    If this sounds like a fair trade, let's go!

    solution

    Implementing Reviews in the Redux Store

    Jack HerringtonJack Herrington

    The first thing we need to do is import our Review type and define a new slice for our reviews.

    Inside of store.tsx, we'll add an interface for the ReviewsState which will contain reviews that will either be an array of reviews or be null. The initialReviews state will be null.

    import { type Cart, type Review } from "@api/types";
    
    export interface ReviewsState {
    	reviews: Review[] | null;
    }
    
    const initialReviews: ReviewsState = {
    	reviews: null,
    };
    

    Next, we'll create our reviewSlice. We'll give it the name reviews, set the initial state to our initialReviews (which is null), and add a new action called setReviews. This action will take the array of reviews and set the state in the store to our initial reviews:

    export const reviewsSlice = createSlice({
    	name: "reviews",
    	initialState: initialReviews,
    	reducers: {
    		setReviews: (state, action: PayloadAction<Review[]>) => {
    			state.reviews = action.payload;
    		},
    	},
    });
    

    Then we add our reviewSlice to the call to createStore:

    export const createStore = () =>
    	configureStore({
    		reducer: {
    			cart: cartSlice.reducer,
    			reviews: reviewsSlice.reducer,
    		},
    	});
    

    Now our Redux store is maintained at the layout level and contains both the cartSlice and our reviewSlice.

    // hovering over RootState
    
    type RootState = {
    	cart: CartState;
    	reviews: ReviewsState;
    };
    

    This will make things interesting when we go to initialize our reviews.

    The last thing we need to do for now is export some actions and selectors.

    We'll export setReviews as well as a selector to use the reviews.

    // Export actions and selectors
    export const { setReviews } = reviewSlice.actions;
    ...
    export const useReviews = () =>
      useSelector((state: RootState) => state.reviews);
    

    With these changes, our review slice is now set up and ready to be used in the product page.

    Updating the Product Detail Page

    Now, let's take a look at the ProductDetail component in page.tsx.

    This React Server Component takes an id which is used to get data about the product we're interested in. This is done asynchronously, which is a key advantage of using React server components.

    The data we're most interested in right now is product.reviews, which we need to get into the store.

    It may be tempting to try to dispatch the setReviews action similarly to how we did it in the store provider, but that won't work:

    // Won't work!
    
    store.dispatch(setReviews(product.reviews));
    

    This approach won't work because we don't know where the store is, because it's located in the client component and being passed down via context.

    Remember the rules of state management with App Router-- we can't use context in an RSC, and the store is not defined as a global variable.

    Like the name suggests, React Server Components only run on the server, so there's no way for them to initialize data on the client side the same way they do on the server.

    To overcome this issue, we need a client component to handle the initialization.

    Fortunately, we happen to have two different client components that access the reviews: Reviews and AverageReview.

    To keep things simple, we'll focus on the Reviews component for now.

    Updating the Reviews Component

    Inside of Reviews.tsx, import useStore from react-redux. This will allow us to get the store that we can't get in the page. However, we also need to give it the type of the store, so we need to bring in RootState as well:

    // inside Reviews.tsx
    
    import { useStore } from "react-redux";
    import { RootState } from "@/app/store/store";
    

    Then inside of the component we can get the store by calling useStore and passing in RootState as a type, and then dispatch the setReviews action with the reviews:

    // inside the `Reviews` component
    const store = useStore<RootState>();
    store.dispatch(setReviews(reviews));
    

    Since we know that we want to use the reviews from the store dynamically, we'll rename the current reviews variable to be initialReviews:

    export default function Reviews({
      reviews: initialReviews,
      addReviewAction,
    }): {
      ...
    

    Then we can dispatch the initialReviews then use the useReviews hook to get the current reviews:

    // inside the `Reviews` component
    
    const store = useStore<RootState>();
    store.dispatch(setReviews(initialReviews));
    const reviews = useReviews();
    

    Let's check our work so far!

    Checking Our Work

    Over in the browser, everything seems to work correctly. The reviews are present in the SSR output, and as we navigate from page to page, we see new reviews.

    However, when we try to add a new review and submit it, nothing appears to happen! Refreshing the page shows that the data made it, but it didn't actually update the reviews.

    Fixing the Review Update Issue

    The issue occurs because when we re-render the Reviews component, the dispatch call sets the store back to the initial reviews. This means that the new review is not being added as expected.

    Trying useEffect

    In order to fix this, we'll wrap the dispatch call in a useEffect hook to ensure that it only runs once:

    useEffect(() => {
    	store.dispatch(setReviews(initialReviews));
    }, []);
    

    Now when we refresh the page, we notice that the reviews don't show up right away. This is because useEffect is only ever triggered on the client after the first render. As a result, we're not getting any server-side rendered output, and the server either has null or an empty array of products.

    So useEffect won't solve our problem.

    Checking the Store for Reviews

    Since using useEffect didn't work as expected, we'll try another approach.

    This time, we'll check the store using the getState function and only dispatch the action to set the initial reviews if there are no reviews present. Otherwise, if there are reviews, we won't dispatch anything:

    const store = useStore<RootState>();
    if (!store.getState().reviews.reviews) {
      store.dispatch(setReviews(initialReviews));
    }
    

    Now when we check the page, we don't have the jumpy behavior we experienced with useEffect, and server-side rendering is functional!

    However, we still have an issue: when we navigate between routes, the reviews don't change even though the product and price information is updated correctly.

    Let's fix it.

    Update Reviews on Route Change

    Remember, our Redux store is effectively global since we defined it at the layout-level with our StoreProvider.

    Even though the page server component and the Reviews client component are getting re-run, we look at the global state of the store and ask: are there reviews? If there are, then don't do that dispatch.

    What we need to do is figure out a way to track if we are getting re-rendered for a new route that is outside the state of the store.

    In order to do this, we'll use the useRef hook to track if we have been initialized or not.

    Since the Reviews component is rebuilt every time we go from route to route, we'll get a new initialized with a new current every time we go from route to route, which will always start at false. Then if we're not initialized, we'll dispatch initialReviews then set current to true to indicate that we are initialized:

    const store = useStore<RootState>();
    const initialized = useRef(false);
    
    if (!initialized.current) {
    	store.dispatch(setReviews(initialReviews));
    	initialized.current = true;
    }
    

    Back in the browser, when we submit a review we get the behavior we were looking for! We are also able to go from page to page with the review updates we want.

    The last thing we need to do is update the AverageRating component to avoid the weird UI "popping".

    Updating Average Rating

    The AverageRating component needs updated to make sure it gets its reviews from the store and not from the initialReviews.

    The AddToCart and Reviews components are peers of one another in the same hierarchy in the JSX. We don't know what the render order is, and as the React docs point out, we should never depend on the order of our components.

    Both of these components are looking for reviews, and AverageRating is getting null even though the reviews should be set since it's getting them from the store.

    In order to fix the issue we have with the UI jumping, we can use the same initialization code in AverageRatings as we did in Reviews. Because product.reviews is the same reference in both places, it will only be set once and it doesn't really matter which one runs in which order.

    We'll copy the initialization code from the Reviews component over to the AverageRating component, and adjust the code to use initialReviews:

    export default function AverageRating({
      reviews: initialReviews,
    }: {
      reviews: Review[];
    }) {
      const store = useStore<RootState>();
      const initialized = useRef(false);
      if (!initialized.current) {
        store.dispatch(setReviews(initialReviews));
        initialized.current = true;
      }
      ...
    

    With these changes, checking in the browser suggests that everything is working as expected! We can also look at the SSR Output source and see that the average rating is calculated correctly.

    Avoiding Repetition in Initialization Code

    You might be concerned about repeating the exact same initialization code in two different components.

    One way to address this would be to create a client component, perhaps called Initializer. Import Initializer in the page.tsx file and put it at the top of the JSX to act as a store initializer. This Initializer component would take the product.reviews and handle the initialization as we've done in the Reviews or AverageRatings. After this, the Reviews and AverageRating components could use the store without initialization.

    But if you really want to check out a cleaner way to achieve this same setup, we'll be implementing a Zustand version of this application in the next exercise!

    Transcript

    Okay, well, I hope that wasn't too difficult. Let's jump into the code and see how I solve this problem. Now, the easiest part of this really starts at the store. You just need to define a new slice for our reviews. So I need to bring in the review type.

    And we need to define our slice. So we're going to have our review state. It's either going to have an array of reviews or it's going to have null. And we're going to start off at null. Then we're going to create our review slice. We're going to give it the name reviews. We're going to give it an initial state of our initial reviews. Again, just null. And then we're going to give it a new action called set reviews.

    That's just going to take the array of reviews and set the state in the store to our initial reviews. All right, let's go take that review slice and add it to our store. Now we go down here to our root state.

    So now we see that our Redux store, which is maintained at the layout level, contains both the cart slice, awesome, and our review slice. And that's going to make it interesting as we get to the point where we need to initialize our reviews.

    OK, I think the last thing we need to do is export some actions. So we'll export set reviews as well as a selector to use the reviews. As I say, this is probably the easiest part. So now let's go into the product page.

    So now we're looking at the product detail page. It's a React server component that takes an ID. And the first thing it does is it gets the important data. It gets the product that we're interested in. It gets a list of products we want to show in the related products.

    And it does both those asynchronously, which is a really nice thing about a React server component. Now, the data that's really interesting to us is, of course, product.reviews. And we have to figure out a way to get the product reviews into the store.

    Now, why couldn't we do something like this? We just dispatch just like we did in the store provider, but this time with the product reviews using that awesome set reviews action creator that we just built. Here's the problem. We don't know where the store is.

    The store is over here in the client component and is passed down via context. You can't use context in a React server component. And the store is not defined as a global, so we can't just access the store as a global. And even if we could access the store, React server components only run on the server.

    So there's not going to be any way for the React server component to do the initialization on the client the same way it does here. So we need a client component to go and do the initialization. Lucky for us, we have two different client components that access the reviews. But now, actually, let's make this a little bit simpler on ourselves.

    And for the moment, let's just forget about average rating. Let's just deal with reviews. All right, now let's go over into our reviews. And first, let's get use store from React Redux. That's going to give us that store that we can't get in the page.

    Now let's get the store, but you need to give it the type of the store, so that's why we need root state. So let's go bring in root state. So far, so good. So let's just dispatch that set reviews. So far, so good.

    So we've got our store. Let's just go dispatch set reviews. But of course, we know this is going to be dynamic, so we also want to use the reviews that's in the store. So really, what we have here are the initial reviews.

    So let's send our initial reviews, and then we'll go and use that use reviews hook to get the current reviews. So bring in user reviews, and we'll get our reviews from that.

    All right, let's give it a try. That seemed to work. Let's go check the SSR, and it looks like the reviews are in the SSR output, so pretty good. And let's actually go from page to page and see what happens.

    So we jump from page to page. That actually seems to work. All right, let's add a new review, and I'll set it to one and submit review. Okay, that's kind of weird. So I hit submit review, and nothing seemed to happen. So let me hit refresh. Okay, so my data actually got in there, which is great,

    but it didn't actually update. So what actually happened there? So what happened was when we called setReviewText and setReviewRating to just reset the review text and review rating,

    we re-rendered the reviews component, which called that dispatch and set the store to our initial reviews.

    Uh-oh. Okay, so how about we use like useEffect, and we'll just wrap this in a useEffect. Now we know that's only going to get run once. Let's see.

    All right, I hit refresh, and I noticed that the reviews actually don't come up right away. They actually kind of just kind of pop like that, and if we look in the HTML,

    we actually don't see our reviews anymore. Uh-oh. Huh. So what's happening here? So what's happening here is that the useEffect is only ever triggered on the client after the first render.

    So we're not getting any server-side rendered output because the server either has null or an empty array of products or whatever the initial kind of starter state of your reviews was. So not great. Can't use useEffect. What else can we do? Well, we can take a look at the store and see if it has reviews,

    and if it already has reviews, then we don't dispatch anything, right? That makes sense. So we'll look at the store. We'll call this handy getState function. That'll get us our state.

    We'll take a look at reviews, and we'll say if we don't have reviews, then we'll dispatch our set of the initial reviews. So far, so good. Okay. Hit save. Now if I refresh, looks good.

    There's none of that jumpy thing that we had with useEffect, so we are getting the result in the SSR, but if I go from route to route, you can actually see that as I go from route to route, the product changes, the price changes, whatever, get all that good stuff, but the reviews don't change. So why is that?

    Well, remember, our Redux store is effectively global since we defined it at the layout level with our store provider. That means as you go from route to route to route, that state is actually maintained from route to route,

    even though the page component, the RSC is getting rerun, the reviews component is getting rerun. Everything's getting rerun, but we look at the global state of that store, and we say, well, are there reviews?

    If there are, yes, then don't do that dispatch. So we have to figure out a way to track if we are getting re-rendered for a new route that is outside the state of the store. To do that, I'm just going to use a simple useRef,

    and we'll have it track if we are initialized or not. Because reviews is going to get rebuilt every time, we're going to get a new initialized with a new current every time we go from route to route, and it's always going to start at false. Now, let's check that.

    And so we're not initialized. We're going to set the reviews using that dispatch of the initial reviews. Of course, we have to say that we are initialized, so we have to set the current to true.

    Okay, so far, so good. So let's go back over to Arc, try again. Hit submit review.

    And it actually did work, and if I go from page to page, that actually works, too. But we don't actually dispatch the set reviews after we've gone and submit a review.

    But the issue is, if we look down here at our ad review action, we don't actually dispatch the set review, so let's go and do that. So we need to bring in use dispatch, and then use dispatch to give ourselves a dispatch, and we've got set reviews already.

    So all we need to really do is just do dispatch set reviews with the output of the action. Okay, let's give it a try. Hit refresh. Hello again. Perfect. Awesome.

    Hit refresh. Let's just double check the SSR, and we have our reviews in there, so we are good to go. Now, one last thing, we've got to go and implement on our average rating. So let's just bring average rating back and see what happens. So just go and uncomment out average rating.

    All right, now that we've brought our average rating back, let's go and implement on average rating to make sure that it gets the reviews from the store and not from its initial reviews.

    So we use these reviews. We'll bring that in from the store. Let's save. We'll refresh, and there's something weird going on here.

    There's like a little popping. Okay, let's see if there's anything in the console. Okay. Well, nothing in the console, but I don't like that popping. So what's happening here?

    Well, what's happening here is it's looking at reviews, and it's initially getting null for the reviews, even though it should be set because it's getting from the store, but here's what's happening. So both of these components, Add to Cart and Reviews, are peers of each other.

    They're in the same hierarchy in the JSX, and we don't really know what the render order is. In fact, actually, Reviews is probably getting rendered second, but according to the React documentation, you should never depend on the render order of your components.

    So how do we fix this? Well, my contention is that you could use the same exact initialization code in both Reviews and Average Rating.

    Why? Because product.reviews is the same reference in both places, so you'll just be setting the reference once, and it doesn't really matter which one runs in which order. Let's give it a try.

    So we'll go in and take our initialization code from here, the order Average Rating, paste it in there, and now let's fix any issues.

    And finally, we need View Store. Hit Save. Let's give it a go.

    Hit Refresh, and now it's solid as a rock. In fact, actually, let's go over to our SSR Output and make sure that our 4.5 is out there. Find 4.5. There we go. Average Rating of 4.5. Perfect.

    So that means that we're actually calculating the average rating on the SSR as well as outputting the product reviews. You might be freaking out saying, whoa, don't repeat yourself. You're putting the same initialization code in two different components. That's not great.

    Okay, fine. What you could do instead is have a client component, possibly up here at the top of the JSX, that would be an initializer component that would take the product reviews and then do that initialization.

    And because it's at the top of the hierarchy, we're going to assume that it's going to get rendered first. Probably a decent assumption, but that would be one way to avoid this replication of this initialization code.

    All right, well, I hope you enjoyed getting into all of the different variations around this initialization of the store and how to do it in a way that works for both server-side rendering as well as changing between routes as well as updating the data in place.

    Those are the kind of checks that you'll need to make in your application as you implement on patterns like this. But if you want to avoid some of that, I got to tell you, the Zustand version that we're going to do next is actually a lot cleaner.

    So join me in the next exercise as we look at how to reimplement all of this using Zustand.