Redux sending two dispatch after each other then the first dispatch get lost

Viewed 704

I learn React Javascript and Redux and now I have this problem.

Here's a codesandbox

Try like this:

  • Search for book title "dep"
  • Watch the log show "Search url is:", when it should show "Starting books search.."

I send two Redux dispatch after each other and the first never gets trough.

Basically it looks like this:

I have a Book Search app for learning React and in the app when press search for a book a dispatch is sent

enter image description here

This is the class sending the state:

import { booksActionTypes } from "./books.types";

export const showLog = () => ({
  type: booksActionTypes.SHOWLOG,
});

export const setLogMessage = (log) => ({
  type: booksActionTypes.LOG_MESSAGE,
  payload: log,
});

export const clearPosts = () => ({
  type: booksActionTypes.CLEAR_POSTS,
});

export const requestStart = () => ({
  type: booksActionTypes.REQUEST_START,
});

export const requestSuccess = (booksList) => ({
  type: booksActionTypes.REQUEST_SUCCESS,
  payload: booksList,
});

export const requestFailure = (errMsg) => ({
  type: booksActionTypes.REQUEST_FAILURE,
  payload: errMsg,
});

export const actionCreators = {
  // "applicationUrl": "http://localhost:51374", http://erikswed.ddns.net:8965/api/BooksXml/getbooks/fromall/?title=dep&author=&genre=&price=
  // "sslPort": 44378
  requestBooks: (book) => async (dispatch, getState) => {
    dispatch(requestStart());
    dispatch(
      setLogMessage(() => ({
        message: "Starting books search..",
        timestamp:
          new Date().getHours() +
          "-" +
          new Date().getMinutes() +
          "-" +
          new Date().getSeconds(),
        type: "connecting",
      }))
    );

    var queryString = Object.keys(book)
      .map((key) => {
        return encodeURIComponent(key) + "=" + encodeURIComponent(book[key]);
      })
      .join("&");
    var url =
      "htftp://erikswed.ddns.net:8965/api/BooksXml/getbooks/fromall/?" +
      queryString;
    dispatch(
      setLogMessage(() => ({
        message: "Search url is: ",
        timestamp:
          new Date().getHours() +
          "-" +
          new Date().getMinutes() +
          "-" +
          new Date().getSeconds(),
        type: "connecting",
      }))
    );
    dispatch(
      setLogMessage(() => ({
        message: "Sdsadsasada: ",
        timestamp:
          new Date().getHours() +
          "-" +
          new Date().getMinutes() +
          "-" +
          new Date().getSeconds(),
        type: "connecting",
      }))
    );
    await fetch(url)
      .then((res) => res.json())
      .then((xml) => {
        dispatch(requestSuccess(xml));
      })
      .catch((errMsg) => {
        console.log(errMsg);
        dispatch(requestFailure(errMsg));
        fetch("books.xml")
          .then((res) => res.text())
          .then((xmlString) => getFromLocalDatabas(dispatch, xmlString, book))
          .catch((err) => {
            dispatch(requestFailure(errMsg));
          });
      });
  },
};
function getFromLocalDatabas(dispatch, xmlFile, book) {
  var parser = new window.DOMParser();
  var xmlDoc = parser.parseFromString(xmlFile, "text/xml");
  var books = xmlDoc.documentElement.childNodes;
  var map = [];
  for (var i = 0; i < books.length; i++) {
    var author = books[i]
      .getElementsByTagName("author")[0]
      .childNodes[0].nodeValue.toLowerCase();
    var title = books[i]
      .getElementsByTagName("title")[0]
      .childNodes[0].nodeValue.toLowerCase();
    var genre = books[i]
      .getElementsByTagName("genre")[0]
      .childNodes[0].nodeValue.toLowerCase();
    var price = books[i]
      .getElementsByTagName("price")[0]
      .childNodes[0].nodeValue.toLowerCase();
    var publish_date = books[i]
      .getElementsByTagName("publish_date")[0]
      .childNodes[0].nodeValue.toLowerCase();
    var description = books[i]
      .getElementsByTagName("description")[0]
      .childNodes[0].nodeValue.toLowerCase();

    if (book.author && author.indexOf(book.author.toLowerCase()) >= 0) {
      sendBackThisBook(map, books[i]);
    }
    if (book.title && title.indexOf(book.title.toLowerCase()) >= 0) {
      sendBackThisBook(map, books[i]);
    }
    if (book.genre && genre.indexOf(book.genre.toLowerCase()) >= 0) {
      sendBackThisBook(map, books[i]);
    }
    if (book.price && price.indexOf(book.price.toLowerCase()) >= 0) {
      sendBackThisBook(map, books[i]);
    }
    if (
      book.publish_date &&
      publish_date.indexOf(book.publish_date.toLowerCase()) >= 0
    ) {
      sendBackThisBook(map, books[i]);
    }
    if (
      book.description &&
      description.indexOf(book.description.toLowerCase()) >= 0
    ) {
      sendBackThisBook(map, books[i]);
    }
  }
  dispatch(requestSuccess(map));
}

function sendBackThisBook(map, book) {
  var index = map.findIndex((x) => x.id == book.getAttribute("id"));
  if (index != -1) {
    console.log(`exist skipping `, book.getAttribute("id"));
    return;
  }
  map.push({
    id: book.getAttribute("id"),
    title: book.getElementsByTagName("title")[0].childNodes[0].nodeValue,
    author: book.getElementsByTagName("author")[0].childNodes[0].nodeValue,
    genre: book.getElementsByTagName("genre")[0].childNodes[0].nodeValue,
    price: book.getElementsByTagName("price")[0].childNodes[0].nodeValue,
    publish_date: book.getElementsByTagName("publish_date")[0].childNodes[0]
      .nodeValue,
    description: book.getElementsByTagName("description")[0].childNodes[0]
      .nodeValue,
  });
}

This is the Class receiving the Redux Store state:

import React, { useEffect, useState } from "react";
import { makeStyles } from "@material-ui/core/styles";
import { connect } from "react-redux";
import Table from "@material-ui/core/Table";
import TableBody from "@material-ui/core/TableBody";
import TableCell from "@material-ui/core/TableCell";
import TableRow from "@material-ui/core/TableRow";
import { Icon } from "@material-ui/core";
import {
  CallMade,
  CallReceived,
  FiberManualRecord,
  Error,
} from "@material-ui/icons";

function createData(message, timestamp, type) {
  return { message, timestamp, type };
}

const rows = [];

const useStyles = makeStyles((theme) => ({
  root: {
    width: "100%",
  },
  icons: {
    fontSize: 18,
  },
  connecting: {
    color: "#11FF0C",
  },
  connected: {
    color: "#11FF0C",
  },
  disconnecting: {
    color: "#FF5050",
  },
  disconnected: {
    color: "#FF5050",
  },
  error: {
    color: "#FF5050",
  },
  request: {
    // color: "#11FF0C"
  },
  response: {
    // color: "#11FF0C"
  },
}));

function Logger(props) {
  const { v4: uuidv4 } = require("uuid");
  const classes = useStyles();
  const [theLog, addLog] = useState([]);

  useEffect(() => {
    if (typeof props.log === "function") {
      const values = props.log();
      addLog([
        ...theLog,
        createData(values.message, values.timestamp, values.type),
      ]);
    }
  }, [props.log]);

  function createData(message, timestamp, type) {
    console.log("s");
    return { message, timestamp, type };
  }

  return (
    <div>
      {props.showLog ? (
        <div className={classes.root}>
          <Table aria-labelledby="tableTitle" size="small">
            <TableBody>
              {theLog.map((row, index) => {
                return (
                  <TableRow
                    key={uuidv4()}
                    hover
                    role="checkbox"
                    tabIndex={-1}
                    key={row.timestamp}
                  >
                    <TableCell padding="checkbox">
                      <Icon>
                        {row.type === "connecting" && (
                          <FiberManualRecord
                            className={`${classes.connecting} ${classes.icons}`}
                          />
                        )}
                        {row.type === "connected" && (
                          <FiberManualRecord
                            className={`${classes.connected} ${classes.icons}`}
                          />
                        )}
                        {row.type === "disconnecting" && (
                          <FiberManualRecord
                            className={`${classes.disconnecting} ${classes.icons}`}
                          />
                        )}
                        {row.type === "disconnected" && (
                          <FiberManualRecord
                            className={`${classes.disconnected} ${classes.icons}`}
                          />
                        )}
                        {row.type === "error" && (
                          <Error
                            className={`${classes.error} ${classes.icons}`}
                          />
                        )}
                        {row.type === "request" && (
                          <CallMade
                            className={`${classes.request} ${classes.icons}`}
                          />
                        )}
                        {row.type === "response" && (
                          <CallReceived
                            className={`${classes.response} ${classes.icons}`}
                          />
                        )}
                      </Icon>
                    </TableCell>
                    <TableCell>{row.message}</TableCell>
                    <TableCell>{row.timestamp}</TableCell>
                  </TableRow>
                );
              })}
            </TableBody>
          </Table>
        </div>
      ) : null}
    </div>
  );
}

function mapStateToProps(state) {
  return {
    log: state.reducer.log,
    showLog: state.reducer.showLog,
  };
}

export default connect(mapStateToProps, null)(Logger);
1 Answers

There are a bunch of things wrong with your code, for starters; you are saving functions in redux state, functions cannot be serialized and anyone working with Redux will see this as a big code smell.

Another thing is that you only save the current message in Redux state but you want all messages so your Logger component is trying to save logs in local state and that is where you go wrong.

When you dispatch the action requestBooks react-redux will batch synchronous state updates so Logger is not rendered twice for each dispatched setLogMessage so the effect in logger is not executed the first time.

You have some problems with the effect as well (missing dependencies) and you can solve them by doing the effect this way:

const {log} = props
useEffect(() => {
  if (typeof log === "function") {
    const values = log();
    addLog(theLog=>[//no dependency on theLog and no stale closure
      ...theLog,
      createData(values.message, values.timestamp, values.type),
    ]);
  }
}, [log]);

Then in your thunk you should give react-redux some time to render so React can run the effect again:

dispatch(
  setLogMessage(() => ({
    message: "Starting books search..",
    timestamp:
      new Date().getHours() +
      "-" +
      new Date().getMinutes() +
      "-" +
      new Date().getSeconds(),
    type: "connecting"
  }))
);
//React will re render so effect in Logger will run
await Promise.resolve();

This is a buggy solution at best and I will suggest starting by not saving functions in redux state and just saving all logs in state so you don't have to create local state in your Logger component.

Update

Here is an example of how redux state updates are batched so your effect does not run on every dispatch because redux is batching state updates and skipping renders.

const { Provider, useDispatch, useSelector } = ReactRedux;
const { createStore, applyMiddleware, compose } = Redux;

const initialState = { count: 1 };
//action types
const ADD = 'ADD';
//action creators
const add = () => ({
  type: ADD,
});
//thunk action that adds twice (dispatch ADD twice) synchronously
//  because redux will batch the App component will not render
//  between dispatches causing the effect to not run twice
const syncAdd = () => (dispatch) => {
  dispatch(add());
  dispatch(add());
};
//Same thunk but async so when first add is dispatched it will put
//  the rest of the code on the event loop and give react time to
//  render, this is buggy and unpredictable and why you should not
//  copy redux state in local state when redux state changes
const asyncAdd = () => (dispatch) => {
  dispatch(add());
  //async syntax does not work on SO snippet (babel too old)
  Promise.resolve().then(() => dispatch(add()));
};
const reducer = (state, { type }) => {
  if (type === ADD) {
    console.log(
      `ADD dispatched, count before reducer updates: ${state.count}`
    );
    return { ...state, count: state.count + 1 };
  }
  return state;
};
//selectors
const selectCount = (state) => state.count;
//creating store with redux dev tools
const composeEnhancers =
  window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(
  reducer,
  initialState,
  composeEnhancers(
    applyMiddleware(
      ({ dispatch, getState }) => (next) => (action) =>
        //improvised thunk middleware
        typeof action === 'function'
          ? action(dispatch, getState)
          : next(action)
    )
  )
);
const App = () => {
  const count = useSelector(selectCount);
  const dispatch = useDispatch();
  React.useEffect(
    () => console.log(`Effect run with count: ${count}`),
    [count]
  );
  return (
    <div>
      {count}
      <button onClick={() => dispatch(syncAdd())}>
        sync add
      </button>
      <button onClick={() => dispatch(asyncAdd())}>
        async add
      </button>
    </div>
  );
};

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.4/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.4/umd/react-dom.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/redux/4.0.5/redux.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-redux/7.2.0/react-redux.min.js"></script>
<div id="root"></div>

Related