From b11ffcaa5277907d3f24ca2cd2f7eb4cdd487955 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sat, 27 Jul 2024 14:58:49 -0400 Subject: [PATCH] Finish part 5 --- .../tutorials/essentials/part-4-using-data.md | 9 ++- .../essentials/part-5-async-logic.md | 78 ++++++++++++------- 2 files changed, 54 insertions(+), 33 deletions(-) diff --git a/docs/tutorials/essentials/part-4-using-data.md b/docs/tutorials/essentials/part-4-using-data.md index b90e64d774..97b7454c13 100644 --- a/docs/tutorials/essentials/part-4-using-data.md +++ b/docs/tutorials/essentials/part-4-using-data.md @@ -558,10 +558,11 @@ const postsSlice = createSlice({ }, // highlight-start selectors: { - // Note that `state` here is just the `PostsState`! - selectAllPosts: state => state, - selectPostById: (state, postId: string) => { - return state.find(user => post.id === postId) + // Note that these selectors are given just the `PostsState` + // as an argument, not the entire `RootState` + selectAllPosts: postsState => postsState, + selectPostById: (postsState, postId: string) => { + return postsState.find(user => post.id === postId) } } // highlight-end diff --git a/docs/tutorials/essentials/part-5-async-logic.md b/docs/tutorials/essentials/part-5-async-logic.md index 6736e8e991..ff0c60532b 100644 --- a/docs/tutorials/essentials/part-5-async-logic.md +++ b/docs/tutorials/essentials/part-5-async-logic.md @@ -639,7 +639,9 @@ Our `` component is already checking for any updates to the posts tha A real API call will probably take some time to return a response, so it's usually a good idea to show some kind of "loading..." indicator in the UI so the user knows we're waiting for data. -We can update our `` to show a different bit of UI based on the `state.posts.status` enum: a spinner if we're loading, an error message if it failed, or the actual posts list if we have the data. While we're at it, this is probably a good time to extract a `` component to encapsulate the rendering for one item in the list as well. +We can update our `` to show a different bit of UI based on the `state.posts.status` enum: a spinner if we're loading, an error message if it failed, or the actual posts list if we have the data. + +While we're at it, this is probably a good time to extract a `` component to encapsulate the rendering for one item in the list as well. The result might look like this: @@ -655,22 +657,29 @@ import { TimeAgo } from '@/components/TimeAgo' import { PostAuthor } from './PostAuthor' import { ReactionButtons } from './ReactionButtons' -import { Post, selectAllPosts, fetchPosts } from './postsSlice' +import { + Post, + selectAllPosts, + selectPostsError, + fetchPosts +} from './postsSlice' + +interface PostExcerptProps { + post: Post +} -const PostExcerpt = ({ post }: { post: Post }) => { +function PostExcerpt({ post }: PostExcerptProps) { return (
-

{post.title}

+

+ {post.title} +

{post.content.substring(0, 100)}

- - - View Post -
) } @@ -678,9 +687,10 @@ const PostExcerpt = ({ post }: { post: Post }) => { export const PostsList = () => { const dispatch = useAppDispatch() const posts = useAppSelector(selectAllPosts) - const postStatus = useAppSelector(state => state.posts.status) + const posts = useAppSelector(selectAllPosts) + const postStatus = useAppSelector(selectPostsStatus) // highlight-next-line - const error = useAppSelector(state => state.posts.error) + const postsError = useAppSelector(selectPostsError) useEffect(() => { if (postStatus === 'idle') { @@ -702,8 +712,8 @@ export const PostsList = () => { content = orderedPosts.map(post => ( )) - } else if (postStatus === 'failed') { - content =
{error}
+ } else if (postStatus === 'rejected') { + content =
{postsError}
} // highlight-end @@ -873,6 +883,8 @@ Remember, **the `create` callback syntax is optional!** The only time you _have_ We're now fetching and displaying our list of posts. But, if we look at the posts, there's a problem: they all now say "Unknown author" as the authors: +**[TODO] Update screenshot here** + ![Unknown post authors](/img/tutorials/essentials/posts-unknownAuthor.png) This is because the post entries are being randomly generated by the fake API server, which also randomly generates a set of fake users every time we reload the page. We need to update our users slice to fetch those users when the application starts. @@ -880,15 +892,14 @@ This is because the post entries are being randomly generated by the fake API se Like last time, we'll create another async thunk to get the users from the API and return them, then handle the `fulfilled` action in the `extraReducers` slice field. We'll skip worrying about loading state for now: ```ts title="features/users/usersSlice.ts" -// highlight-next-line -import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit' +import { createSlice, PayloadAction } from '@reduxjs/toolkit' // highlight-next-line import { client } from '@/api/client' import type { RootState } from '@/app/store' - -import { selectCurrentUsername } from '../auth/authSlice' +// highlight-next-line +import { createAppAsyncThunk } from '@/app/withTypes' interface User { id: string @@ -896,13 +907,13 @@ interface User { } // highlight-start -export const fetchUsers = createAsyncThunk('users/fetchUsers', async () => { +export const fetchUsers = createAppAsyncThunk('users/fetchUsers', async () => { const response = await client.get('/fakeApi/users') return response.data }) -// highlight-end const initialState: User[] = [] +// highlight-end const usersSlice = createSlice({ name: 'users', @@ -918,11 +929,13 @@ const usersSlice = createSlice({ }) export default usersSlice.reducer + +// omit selectors ``` -You may have noticed that this time the case reducer isn't using the `state` variable at all. Instead, we're returning the `action.payload` directly. Immer lets us update state in two ways: either _mutating_ the existing state value, or _returning_ a new result. If we return a new value, that will replace the existing state completely with whatever we return. (Note that if you want to manually return a new value, it's up to you to write any immutable update logic that might be needed.) +You may have noticed that this time the case reducer isn't using the `state` variable at all. Instead, we're returning the `action.payload` directly. **Immer lets us update state in two ways: either _mutating_ the existing state value, or _returning_ a new result**. If we return a new value, that will replace the existing state completely with whatever we return. (Note that if you want to manually return a new value, it's up to you to write any immutable update logic that might be needed.) -In this case, the initial state was an empty array, and we probably could have done `state.push(...action.payload)` to mutate it. But, in our case we really want to replace the list of users with whatever the server returned, and this avoids any chance of accidentally duplicating the list of users in state. +The initial state was an empty array, and we probably could have done `state.push(...action.payload)` to mutate it. But, in our case we really want to replace the list of users with whatever the server returned, and this avoids any chance of accidentally duplicating the list of users in state. :::info @@ -961,7 +974,7 @@ async function start() { start() ``` -Notice that this is a valid way to fetch data on startup. This actually starts the fetching process _before_ we start rendering our React components, so the data should be available sooner. +Notice that this is a valid way to fetch data on startup. This actually starts the fetching process _before_ we start rendering our React components, so the data should be available sooner. (Note that this principle can be applied by using [React Router data loaders](https://reactrouter.com/en/main/route/loader) as well.) Now, each of the posts should be showing a username again, and we should also have that same list of users shown in the "Author" dropdown in our ``. @@ -973,14 +986,18 @@ We have one more step for this section. When we add a new post from the `` as an argument, and makes an HTTP POST call to the fake API to save the data. -In the process, we're going to change how we work with the new post object in our reducers. Currently, our `postsSlice` is creating a new post object in the `prepare` callback for `postAdded`, and generating a new unique ID for that post. In most apps that save data to a server, the server will take care of generating unique IDs and filling out any extra fields, and will usually return the completed data in its response. So, we can send a request body like `{ title, content, user: userId }` to the server, and then take the complete post object it sends back and add it to our `postsSlice` state. +In the process, we're going to change how we work with the new post object in our reducers. Currently, our `postsSlice` is creating a new post object in the `prepare` callback for `postAdded`, and generating a new unique ID for that post. In most apps that save data to a server, the server will take care of generating unique IDs and filling out any extra fields, and will usually return the completed data in its response. So, we can send a request body like `{ title, content, user: userId }` to the server, and then take the complete post object it sends back and add it to our `postsSlice` state. We'll also extract a `NewPost` type to represent the object that gets passed into the thunk. ```ts title="features/posts/postsSlice.ts" +type PostUpdate = Pick +// highlight-next-line +type NewPost = Pick + // highlight-start -export const addNewPost = createAsyncThunk( +export const addNewPost = createAppAsyncThunk( 'posts/addNewPost', // The payload creator receives the partial `{title, content, user}` object - async initialPost => { + async (initialPost: NewPost) => { // We send the initial data to the fake API server const response = await client.post('/fakeApi/posts', initialPost) // The response includes the complete post object, including unique ID @@ -993,13 +1010,14 @@ const postsSlice = createSlice({ name: 'posts', initialState, reducers: { + // highlight-next-line // The existing `postAdded` reducer and prepare callback were deleted reactionAdded(state, action) {}, // omit logic postUpdated(state, action) {} // omit logic }, extraReducers(builder) { builder - // omit the cases for `fetchPosts` and `userLoggedOut + // omit the cases for `fetchPosts` and `userLoggedOut` // highlight-start .addCase(addNewPost.fulfilled, (state, action) => { // We can directly add the new post object to our posts array @@ -1012,7 +1030,9 @@ const postsSlice = createSlice({ ### Checking Thunk Results in Components -Finally, we'll update `` to dispatch the `addNewPost` thunk instead of the old `postAdded` action. Since this is another API call to the server, it will take some time and _could_ fail. The `addNewPost()` thunk will automatically dispatch its `pending/fulfilled/rejected` actions to the Redux store, which we're already handling. We _could_ track the request status in `postsSlice` using a second loading enum if we wanted to, but for this example let's keep the loading state tracking limited to the component. +Finally, we'll update `` to dispatch the `addNewPost` thunk instead of the old `postAdded` action. Since this is another API call to the server, it will take some time and _could_ fail. The `addNewPost()` thunk will automatically dispatch its `pending/fulfilled/rejected` actions to the Redux store, which we're already handling. + +We _could_ track the request status in `postsSlice` using a second loading enum if we wanted to. But, for this example let's keep the loading state tracking limited to the component, to show what else is possible. It would be good if we can at least disable the "Save Post" button while we're waiting for the request, so the user can't accidentally try to save a post twice. If the request fails, we might also want to show an error message here in the form, or perhaps just log it to the console. @@ -1071,11 +1091,11 @@ export const AddPostForm = () => { We can add a loading status enum field as a React `useState` hook, similar to how we're tracking loading state in `postsSlice` for fetching posts. In this case, we just want to know if the request is in progress or not. -When we call `dispatch(addNewPost())`, the async thunk returns a `Promise` from `dispatch`. We can `await` that promise here to know when the thunk has finished its request. But, we don't yet know if that request succeeded or failed. +When we call `dispatch(addNewPost())`, the async thunk returns a Promise from `dispatch`. We can `await` that promise here to know when the thunk has finished its request. But, we don't yet know if that request succeeded or failed. -`createAsyncThunk` handles any errors internally, so that we don't see any messages about "rejected Promises" in our logs. It then returns the final action it dispatched: either the `fulfilled` action if it succeeded, or the `rejected` action if it failed. +`createAsyncThunk` handles any errors internally, so that we don't see any messages about "rejected Promises" in our logs. It then returns the final action it dispatched: either the `fulfilled` action if it succeeded, or the `rejected` action if it failed. That means that **`await dispatch(someAsyncThunk())` _always_ "succeeds", and the result is the action object itself**. -However, it's common to want to write logic that looks at the success or failure of the actual request that was made. Redux Toolkit adds a `.unwrap()` function to the returned `Promise`, which will return a new `Promise` that either has the actual `action.payload` value from a `fulfilled` action, or throws an error if it's the `rejected` action. This lets us handle success and failure in the component using normal `try/catch` logic. So, we'll clear out the input fields to reset the form if the post was successfully created, and log the error to the console if it failed. +However, it's common to want to write logic that looks at the success or failure of the actual request that was made. **Redux Toolkit adds a `.unwrap()` function to the returned Promise**, which will return a new Promise that either has the actual `action.payload` value from a `fulfilled` action, or throws an error if it's the `rejected` action. This lets us handle success and failure in the component using normal `try/catch` logic. So, we'll clear out the input fields to reset the form if the post was successfully created, and log the error to the console if it failed. If you want to see what happens when the `addNewPost` API call fails, try creating a new post where the "Content" field only has the word "error" (without quotes). The server will see that and send back a failed response, so you should see a message logged to the console.