Implementing Reviews in the Redux Store
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!