Allow me to paint a scenario: you have a React application that connects to a relational database (let's say you're using Ruby on Rails' Active Record). You are using asynchronous Redux with Thunk (Check out Redux Toolkit!) to fetch data from three different models and save them into state.
The models and their relationships are as follows:
So would we need to create a slice for all three models in order to manage all of our state? Well, no. Assuming that both a user and a movie are serialized to include an array of their reviews, a reviewsSlice isn't necessary; all of the reviews can be accessed and updated through association methods. That leaves us needing one slice to handle the current user in the session hash (sessionsSlice), and another to manage the movies from the database (moviesSlice).
// Asynchronous action creator sends a GET request to your
// SessionsController in the back end
export const fetchUser = createAsyncThunk("sessions/fetchUser", () => {
return fetch("/your-route-for-the-current-user")
.then((response) => response.json())
.then(data => data)
});
const sessionsSlice = createSlice({
name: "sessions",
initialState: {
currentUser: {},
status: "idle",
},
reducers: { ... },
extraReducers: builder => {
// Dispatching fetchUser() in the front end will run the .pending
// action first, then the .fulfilled action when the response from
// the GET request comes back
builder
.addCase(fetchUser.pending, (state) => {
state.status = "loading";
})
.addCase(fetchUser.fulfilled, (state, action) => {
state.currentUser = action.payload;
state.status = "idle";
})
}
// We can directly mutate state thanks to Immer, included in the
// Redux Toolkit!
The code to get the movies will look very similar; only the endpoint in the fetch and the initialState should differ (and the name of the slice and async action, but you knew that, right?).
Now when we access state, we should be able to call currentUser.reviews (or .reviews on any given movie) and render accordingly. We have all of our data in state, and it only took two fetches! But how do we update state in both slices whenever we create, edit, or delete a review? Are we sending two POST requests? Heck no!
The key is going to be writing additional actions to dispatch alongside your review-related requests. A review has both a user_id and a movie_id, so it would make sense to at least have your create and update actions in the back end be through one of these associations. Since the current user is the one performing these actions, it makes the most sense to create and update using their id. Thus, we will be performing all of our asynchronous actions in sessionsSlice:
export const addReview = createAsyncThunk("reviews/addReview",
(reviewObj) => {
return fetch('/reviews', {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(reviewObj)
})
.then(r => r.json())
.then(data => data) // this line gives us our action.payload,
// i.e. the new review
})
// Down in extraReducers...
.addCase(addReview.pending, (state) => {
state.status = "loading";
})
.addCase(addReview.fulfilled, (state, action) => {
state.currentUser.reviews.push(action.payload);
state.status = "idle";
})
You can see from the above code that we are passing in a 'reviewObj' parameter for the body of the POST request. This is simply the controlled form data from your review creating component and the id of the movie being reviewed:
const reviewObj = {
movie_id: movie.id, // from props
content: form.content, // from controlled form
rating: form.rating,
}
So if you can pass this object in when you dispatch addReview, you can also use it to update the movie's reviews, right? Well, let's see...
const moviesSlice = createSlice({
name: "movies",
initialState: {
entities: [], // array of movies
status: "idle", // loading state
},
reducers: {
addMovieReview(state, action) {
const movie = state.entities.find(m => m.id ===
action.payload.movie_id);
movie.reviews.push(action.payload)
}
}
// And in the review form component...
const handleSubmit = (e) => {
e.preventDefault()
dispatch(addReview(reviewObj))
dispatch(addMovieReview({
...reviewObj,
user_id: currentUser.id // brought in from the store
}))
}
Now both the user's reviews and the movie's reviews are updated with one click, and Redux automatically rerenders the app to reflect the changes. While this seems alright on the surface, there is one glaring issue: there is no accounting for failure. If the review doesn't pass validations on the back end, we get an error pushed into our user reviews and an invalid review attributed to our movie. So how do we fix this?
We start by removing the second dispatch from our review form's handleSubmit. There's nothing wrong with the addMovieReview action, we just won't be calling it here. Next we will be adding a few things in sessionsSlice: error handling and some shiny new variables in initialState.
name: "sessions",
initialState: {
currentUser: {},
review: null, // for storing a new/updated review instance
errors: [], // to push error messages into
status: "idle"
},
reducers: {
// We'll throw some new actions in here so we can update our
// new variables from the components
setReview(state, action) {
state.review = action.payload
},
setErrors(state, action) {
state.errors = action.payload
}
},
extraReducers: builder => {
builder
.addCase(addReview.pending, (state) => {
state.status = "loading";
})
.addCase(addReview.fulfilled, (state, action) => {
// Here we conditionally handle the error key that we return
// from the back end, should it show up in our response
if (action.payload.error) {
state.errors = action.payload.error
state.status = "idle"
}
else {
// If the payload does not contain the error key, we can add
// the new review to the user object in state and store it
// inside of our new review variable as well
state.currentUser.reviews.push(action.payload);
state.review = action.payload
state.status = "idle";
})
}
}
The two new variables can now be used in combination with the useEffect hook at our app's top level component. The errors variable can be used to inform the user should their request go awry. I like to use a pop-up or overlay to render the errors, with a 'dismiss' or 'x' button that can dispatch(setErrors(null))
on click, but you could also opt to reset the errors inside of addReview.fulfilled after a successful post is made. More importantly, we can now finally add our new review to its respective movie. Behold:
// App.js
const review = useSelector(state => state.sessions.review)
useEffect(() => {
if (review) {
dispatch(addMovieReview(review))
dispatch(setReview(null))
// Here you can programmatically navigate to another page,
// or anything else you want to do after a review is created
}
}, [review])
So for a quick rundown:
- The user submits a new review, dispatching the addReview action in sessionsSlice
- If the review clears validations, it is added into state in sessions.currentUser.reviews and stored in sessions.review
- The review variable is pulled from state in the application's top-level component, triggering a useEffect the second it becomes a truthy value (i.e., the new review instance)
- The useEffect dispatches two actions: one to add the new review to the corresponding movie, and the other to reset the review variable to null
And there you have it! Two slices of state updated with a single click (though there's a lot more going on behind the scenes)! This same flow can be applied to update and destroy actions as well, with the use of the review-in-question's id. See if you can get it figured out on your own, or check out the Redux Toolkit documentation, where there's certain to be a way better solution that I overlooked. You've got this! Shoot, if you made it through this roller coaster of a blog, there's probably nothing you can't do.
Top comments (1)
Hey, thanks for writing this! One quick note: we officially deprecated the "object" form of
createSlice.extraReducers
in RTK 1.9, and we've already removed it in the RTK 2.0 alphas. Use the "builder" syntax instead:See redux-toolkit.js.org/api/createSli... for more examples.