Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Passing an AsyncThunkAction to unwrapResult

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?

like image 520
Jimmy Avatar asked Aug 29 '20 17:08

Jimmy


2 Answers

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.

like image 164
Jimmy Avatar answered Oct 10 '22 20:10

Jimmy


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.

like image 25
Ben Golding Avatar answered Oct 10 '22 21:10

Ben Golding