I'm following the Redux Essentials tutorial and I've run into a problem in part 5, Async Logic and Data Fetching. I'm using TypeScript even though TypeScript is not used in the tutorial because I'm trying to learn both Redux and TypeScript at once.
In the section Checking Thunk Results in Components, I'm getting a type error when calling Redux's unwrapResult function that I have not been able to figure out.
Here's the error:
TypeScript error in redux-essentials-example-app/src/features/posts/AddPostForm.tsx(34,22):
Argument of type 'AsyncThunkAction<Post, InitialPost, {}>' is not assignable to parameter of type 'ActionTypesWithOptionalErrorAction'.
Property 'payload' is missing in type 'AsyncThunkAction<Post, InitialPost, {}>' but required in type '{ error?: undefined; payload: any; }'. TS2345
32 | setAddRequestStatus("pending");
33 | const result = await dispatch(addNewPost({ title, content, user: userId }));
> 34 | unwrapResult(result);
| ^
35 | setTitle("");
36 | setContent("");
37 | setUserId("");
Here is the full contents of my typed version of AddPostForm.tsx:
import React, { useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { unwrapResult } from "@reduxjs/toolkit";
import { RootState } from "../../app/store";
import { addNewPost } from "./postsSlice";
export default function AddPostForm() {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [userId, setUserId] = useState("");
const [addRequestStatus, setAddRequestStatus] = useState("idle");
const dispatch = useDispatch();
const users = useSelector((state: RootState) => state.users);
const onTitleChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
setTitle(e.target.value);
};
const onContentChanged = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setContent(e.target.value);
};
const onAuthorChanged = (e: React.ChangeEvent<HTMLSelectElement>) => {
setUserId(e.target.value);
};
const canSave = [title, content, userId].every(Boolean) && addRequestStatus === "idle";
const onSavePostClicked = async () => {
if (canSave) {
try {
setAddRequestStatus("pending");
const result = await dispatch(addNewPost({ title, content, user: userId }));
unwrapResult(result);
setTitle("");
setContent("");
setUserId("");
} catch (err) {
console.error("Failed to save the post: ", err);
} finally {
setAddRequestStatus("idle");
}
}
};
const usersOptions = users.map(user => (
<option key={user.id} value={user.id}>
{user.name}
</option>
));
return (
<section>
<h2>Add a new post</h2>
<form>
<label htmlFor="postTitle">Post Title:</label>
<input type="text" id="postTitle" name="postTitle" value={title} onChange={onTitleChanged} />
<label htmlFor="postAuthor">Author:</label>
<select id="postAuthor" value={userId} onChange={onAuthorChanged}>
<option value=""></option>
{usersOptions}
</select>
<label htmlFor="postContent">Content:</label>
<textarea id="postContent" name="postContent" value={content} onChange={onContentChanged} />
<button type="button" onClick={onSavePostClicked} disabled={!canSave}>Save post</button>
</form>
</section>
);
}
And here is the full contents of my typed version of redux-essentials-example-app/src/features/posts/postsSlice.tsx:
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
import { RootState } from "../../app/store";
import { client } from "../../api/client";
export interface PostState {
posts: Post[],
status: "idle" | "loading" | "succeeded" | "failed",
error: string | null,
}
export interface Post {
id: string,
date: string,
title: string,
content: string,
user: string,
reactions: Reactions,
}
export interface Reactions {
thumbsUp: number,
hooray: number,
heart: number,
rocket: number,
eyes: number,
[key: string]: number,
}
const initialState: PostState = {
posts: [],
status: "idle",
error: null,
};
export const fetchPosts = createAsyncThunk("posts/fetchPosts", async () => {
const response = await client.get("/fakeApi/posts");
return response.posts;
});
interface InitialPost {
title: string,
content: string,
user: string,
}
export const addNewPost = createAsyncThunk<Post, InitialPost>(
"posts/addNewPost",
async (initialPost) => {
const response = await client.post("/fakeApi/posts", { post: initialPost });
return response.post;
}
);
const postsSlice = createSlice({
name: "posts",
initialState,
reducers: {
postUpdated: (state, action) => {
const { id, title, content } = action.payload;
const existingPost = state.posts.find(post => post.id === id);
if (existingPost) {
existingPost.title = title;
existingPost.content = content;
}
},
reactionAdded: (state, action) => {
const { postId, reaction } = action.payload;
const existingPost = state.posts.find((post: Post) => post.id === postId);
if (existingPost) {
existingPost.reactions[reaction]++;
}
},
},
extraReducers: builder => {
builder.addCase(fetchPosts.pending, (state) => {
state.status = "loading";
});
builder.addCase(fetchPosts.fulfilled, (state, action) => {
state.status = "succeeded";
state.posts = state.posts.concat(action.payload);
});
builder.addCase(fetchPosts.rejected, (state, action) => {
state.status = "failed";
if (action.error.message) {
state.error = action.error.message;
}
});
builder.addCase(addNewPost.fulfilled, (state, action) => {
state.posts.push(action.payload);
});
},
});
export const { postUpdated, reactionAdded } = postsSlice.actions;
export function selectAllPosts(state: RootState) {
return state.posts.posts;
}
export function selectPostById(state: RootState, postId: string) {
return state.posts.posts.find((post: Post) => post.id === postId);
}
export default postsSlice.reducer;
I looked at the source code of Redux Toolkit and the ActionTypesWithOptionalErrorAction type is not exported, so it seems that the object being passed to unwrapResult needs to be a certain shape rather than declared as a certain type. The type error says the payload property is missing, but it's definitely there if I comment out the unwrapResult call and instead inspect the object with console.log(result). So it seems to be an issue of getting the types correct rather than a logical error in the code. How do I type this correctly?