Skip to content

Commit

Permalink
Finish part 5
Browse files Browse the repository at this point in the history
  • Loading branch information
markerikson committed Jul 27, 2024
1 parent 7b21b84 commit b11ffca
Show file tree
Hide file tree
Showing 2 changed files with 54 additions and 33 deletions.
9 changes: 5 additions & 4 deletions docs/tutorials/essentials/part-4-using-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
78 changes: 49 additions & 29 deletions docs/tutorials/essentials/part-5-async-logic.md
Original file line number Diff line number Diff line change
Expand Up @@ -639,7 +639,9 @@ Our `<PostsList>` 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 `<PostsList>` 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 `<PostExcerpt>` component to encapsulate the rendering for one item in the list as well.
We can update our `<PostsList>` 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 `<PostExcerpt>` component to encapsulate the rendering for one item in the list as well.

The result might look like this:

Expand All @@ -655,32 +657,40 @@ 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 (
<article className="post-excerpt" key={post.id}>
<h3>{post.title}</h3>
<h3>
<Link to={`/posts/${post.id}`}>{post.title}</Link>
</h3>
<div>
<PostAuthor userId={post.user} />
<TimeAgo timestamp={post.date} />
</div>
<p className="post-content">{post.content.substring(0, 100)}</p>

<ReactionButtons post={post} />
<Link to={`/posts/${post.id}`} className="button muted-button">
View Post
</Link>
</article>
)
}

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') {
Expand All @@ -702,8 +712,8 @@ export const PostsList = () => {
content = orderedPosts.map(post => (
<PostExcerpt key={post.id} post={post} />
))
} else if (postStatus === 'failed') {
content = <div>{error}</div>
} else if (postStatus === 'rejected') {
content = <div>{postsError}</div>
}
// highlight-end

Expand Down Expand Up @@ -873,36 +883,37 @@ 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.

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
name: string
}

// highlight-start
export const fetchUsers = createAsyncThunk('users/fetchUsers', async () => {
export const fetchUsers = createAppAsyncThunk('users/fetchUsers', async () => {
const response = await client.get<User[]>('/fakeApi/users')
return response.data
})
// highlight-end

const initialState: User[] = []
// highlight-end

const usersSlice = createSlice({
name: 'users',
Expand All @@ -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

Expand Down Expand Up @@ -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 `<AddPostForm>`.

Expand All @@ -973,14 +986,18 @@ We have one more step for this section. When we add a new post from the `<AddPos

We can use `createAsyncThunk` to help with sending data, not just fetching it. We'll create a thunk that accepts the values from our `<AddPostForm>` 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<Post, 'id' | 'title' | 'content'>
// highlight-next-line
type NewPost = Pick<Post, 'title' | 'content' | 'user'>

// 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<Post>('/fakeApi/posts', initialPost)
// The response includes the complete post object, including unique ID
Expand All @@ -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
Expand All @@ -1012,7 +1030,9 @@ const postsSlice = createSlice({

### Checking Thunk Results in Components

Finally, we'll update `<AddPostForm>` 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 `<AddPostForm>` 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.

Expand Down Expand Up @@ -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.

Expand Down

0 comments on commit b11ffca

Please sign in to comment.