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?
I figured it out. It's necessary to specify the "dispatch" type when calling useDispatch
from react-redux. This is described in Usage With TypeScript: Getting the Dispatch
Type.
In the end, I added these extra types to the file where my Redux store is being created:
export type AppDispatch = typeof store.dispatch;
export function useAppDispatch() {
return useDispatch<AppDispatch>();
}
And then in src/features/posts/AddPostForm.tsx, I imported useAppDispatch
instead of useDispatch
and used that.
I just came across this while trying to solve the same problem. Rather than create a useAppDispatch()
function to fiddle with the types, I realised that my store from configureMiddleware wasn’t including the thunk types as dispatch actions.
On this page, they say that using a callback to the middleware definition will include the correct typing. Where I had been using:
const store = configureStore({
reducer: rootReducer,
middleware: […getDefaultMiddleware(), logger]
})
changing that to
const store = configureStore({
reducer: rootReducer,
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(logger)
})
actually fixed the problem because store.dispatch()
now included the AsyncThunkAction
in the list of accepted parameter types. This is a better solution but I wish it was included in the redux toolkit docs about configureStore
.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With