How to cancel previous onClick handler that has two dispatch functions with useEffect()?

Viewed 86

I saw this https://redux-toolkit.js.org/api/createAsyncThunk#canceling-while-running but I have two dispatch functions inside an onClick handler. How can I put them into useEffect()?

I want to achieve when user quickly clicks on another item, the previous fetchFileContentsHandler will be cancelled, and always return the result from the latest invoke.

I tried to wrap fetchFileContentsHandler in useCallback(), and put fetchFileContentsHandler inside useEffect() with clean up return. but it resulted in strange behavior.

  const fetchFileContentsHandler = () => {
    dispatch(fetchFileContents(props.params));
    dispatch(
      changeSelectedFile({ selectedFile: props.params, selected: true })
    );
  };

  return (
    <NavLink to="#" key={fileName}>
      <ListItemButton
        selected={props.selected}
        onClick={fetchFileContentsHandler}
      >
        <ListItemText
          primary={fileName}
        />
      </ListItemButton>
    </NavLink>
  );
};

export default Items;

[update] I tried to change fetchFileContents to createAsyncThunk and add the suggested useRef() and .abort(), but it didn't work.

If the first fetchFileContents takes longer than the second fetchFileContents, it still updates the htmlReport or textReport value once it's fulfilled.

Then once htmlReport or textReport value changes, I display its contents in another component. So I can still see the first fetched file contents display after the second fetched file contents.

slice.js

export const fetchFileContents = createAsyncThunk(
  "ui/getFileContents",
  async (fileName) => {
    const fileContentsResponse = await fetch(...);
    const response = await fileContentsResponse.json();

    return {
      fileType: fileName.includes("html") ? "text" : "html",
      contents: data,
    };
  }
);
const Slice = createSlice({
  name: "somename",
  initialState: {
    htmlReport: null,
    textReport: null,
  },
  reducers: {
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchFileContents.fulfilled, (state, action) => {
        if (action.payload.fileType === "text") {
          state.textReport = action.payload.contents;
          state.htmlReport = null;
        } else if (action.payload.fileType === "html") {
          state.textReport = null;
          state.htmlReport = action.payload.contents;
        }
      })
      .addCase(fetchFileContents.rejected, (state, action) => {
        console.log("[REJECTED]: ");
        const { fileName } = action.meta.arg;
        const { message } = action.error;
        console.log(fileName, message);
      });
  },
});

Component.js

const fetchFileContentsPromise = useRef();

  const fetchFileContentsHandler = () => {
    fetchFileContentsPromise.current?.abort();

    fetchFileContentsPromise.current = dispatch(
      fetchFileContents({ fileName: props.params })
    );
    dispatch(
      changeSelectedFile({ selectedFile: props.params, selected: true })
    );
  };

[update] I can't find any major differences between your codes and mine. Your log was correctly recorded:

undefined
Fetching data with A...
Promise .....{fileName: 'A'.....
Fetching data with C...
[REJECTED]: A, I canceled it.
Promise resolved: CCC
[FULFILLED]: CCC
Promise resolved: AAA

But my log looks like this:

undefined
Fetching data with A...
undefined
Fetching data with C...
[FULFILLED]: CCC
[Violation] 'setTimeout' handler took 55ms
[FULFILLED]: AAA

or when I repeat:

Promise .....{fileName: 'A'.....
Fetching data with A...
Promise .....{fileName: 'C'.....
Fetching data with C...
[FULFILLED]: CCC
[Violation] 'setTimeout' handler took 55ms
[FULFILLED]: AAA

[update -- Solved] I figured out that I had to declare const fetchFileContentsPromise = useRef(); in the parent component and use props to send it to the child then send it to child's child again.

I use a nested object and pick the necessary values to parent -> child(1) -> child(2). This fetchFileContents is in child(2).

But I don't understand why.

I would be really appreciated it if anyone could explain.

1 Answers

You're pretty close!

If you scroll down a little bit in the documentation you found, you'll see that it says the dispatch(asyncThunk) will return a promise with an abort method.

In your use case, we only want previous requests to be canceled when the button is click. useEffect could work, but we'll have to create some extra state to put it in the dependencies array, which is kind of weird.

Another possible option is to use useRef to store those promises, so that we don't have to deal with useEffect. I'm not sure if both of the actions are async thunk, if yes you can try:

const fetchFileContentsPromise = useRef()
const changeSelectedFilePromise = useRef()

const fetchFileContentsHandler = () => {
  // Cancel previous requests
  fetchFileContentsPromise.current?.abort();
  changeSelectedFilePromise.current?.abort();

  // Dispatch actions and send new requests
  fetchFileContentsPromise.current = dispatch(fetchFileContents(props.params));
  changeSelectedFilePromise.current = dispatch(
    changeSelectedFile({ selectedFile: props.params, selected: true })
  );
}

If changeSelectedFile is not an action created by createAsyncThunk, then it's really not possible to stop that because it's synchronous.

In that case I would suggest you to move the logic in changeSelectedFile into the extra reducer when fetchFileContents is fulfilled, so that we only execute those code when fetchFileContents is done.

Related