-1

I'm working on a mernstack app where I have a custom hook for API requests with useReducer's state and dispatch functions that is loaded into the context api. Usually GET request runs smoothly on page load, but every time I use the POST, PATCH, PUT, and DELETE request functions it causes a component to unmount and get this error:

Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.

The error goes away whenever I refresh the page and see the changes. How to prevent React state update for asynchronous request on unmounted component?

Database Setup

const mongodb = require('mongodb');
const { MongoClient, ObjectID } = mongodb;
require('dotenv').config();
const mongourl = process.env.MONGO_URI;
const db_name = process.env.DB_NAME;
let db;

async function startConnection(cb) {
  let client;    
  try {
    client = await MongoClient.connect(mongourl, {
      useNewUrlParser: true,
      useUnifiedTopology: true,
    });
    db = client.db(db_name);
    await cb();
  } catch (err) {
    await cb(err);
  }
}

const getDb = () => {
  return db;
};

const getPrimaryKey = (_id) => {
  return ObjectID(_id);
};

module.exports = { db, startConnection, getDb, getPrimaryKey };

Server:

const express = require('express');
require('dotenv').config();
const port = process.env.PORT || 8000;
const db = require('./db');
const db_col = process.env.DB_COL;
const router = express.Router();
let status;

db.startConnection((err) => {
  if (err) {
    status = `Unable to connect to the database ${err}`;
    console.log(status);
  } else {
    status = 'Connected to the database';
    console.log(status);
  }
});

const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use('/list', router);

router.get('/', (req, res) => {
  db.getDb()
    .collection(db_col)
    .find({})
    .toArray((err, docs) => {
      if (err) {
        console.log(err);
      }
      res.json(docs);
      console.log(docs);
    });
});

router.post('/', (req, res) => {
  const newlist = req.body;
  const { list_name, list_items } = newlist;
  db.getDb()
    .collection(db_col)
    .insertOne({ list_name, list_items }, (err, docs) => {
      if (err) {
        console.log(err);
      }
      res.redirect('/');
      console.log(docs);
    });
});

router.patch('/:id', (req, res) => {
  const paramID = req.params.id;
  const listname = req.body.list_name;
  db.getDb()
    .collection(db_col)
    .updateOne(
      { _id: db.getPrimaryKey(paramID) },
      { $set: { list_name: listname } },
      (err, docs) => {
        if (err) {
          console.log(err);
        }
        res.redirect('/');
        console.log(docs);
      }
    );
});

router.put('/:id', (req, res) => {
  const paramID = req.params.id;
  const listitems = req.body.list_items;
  db.getDb()
    .collection(db_col)
    .updateOne(
      { _id: db.getPrimaryKey(paramID) },
      { $set: { list_items: listitems } },
      (err, docs) => {
        if (err) {
          console.log(err);
        }
        res.redirect('/');
        console.log(docs);
      }
    );
});

router.delete('/:id', (req, res) => {
  const paramID = req.params.id;
  db.getDb()
    .collection(db_col)
    .deleteOne({ _id: db.getPrimaryKey(paramID) }, (err, docs) => {
      if (err) {
        console.log(err);
      }
      res.redirect('/');
      console.log(docs);
    });
});

app.listen(port, console.log(`Server listening to port: ${port}`));

Actions:

import { LOADING, PROCESSING_REQUEST, HANDLING_ERROR } from './actionTypes';

const loading = () => {
  return {
    type: LOADING,
  };
};

const processingRequest = (params) => {
  return {
    type: PROCESSING_REQUEST,
    response: params,
  };
};

const handlingError = () => {
  return {
    type: HANDLING_ERROR,
  };
};

export { loading, processingRequest, handlingError };

Reducer:

import {
  LOADING,
  PROCESSING_REQUEST,
  HANDLING_ERROR,
} from './actions/actionTypes';

export const initialState = {
  isError: false,
  isLoading: false,
  data: [],
};

const listReducer = (state, { type, response }) => {
  switch (type) {
    case LOADING:
      return {
        ...state,
        isLoading: true,
        isError: false
      };
    case PROCESSING_REQUEST:
      return {
        ...state,
        isLoading: false,
        isError: false,
        data: response,
      };
    case HANDLING_ERROR:
      return {
        ...state,
        isLoading: false,
        isError: true
      };
    default:
      throw new Error();
  }
};

export default listReducer;

Custom Hook for API Requests:

import { useEffect, useCallback, useReducer } from 'react';
import axios from 'axios';
import listReducer, { initialState } from '../../context/reducers/reducers';
import {
  loading,
  processingRequest,
  handlingError,
} from '../../context/reducers/actions/actionCreators';

const useApiReq = () => {
  const [state, dispatch] = useReducer(listReducer, initialState);

  const getRequest = useCallback(async () => {
    dispatch(loading());
    try {
      const response = await axios.get('/list');
      dispatch(processingRequest(response.data));
    } catch (err) {
      dispatch(handlingError);
    }
  }, []);

  const postRequest = useCallback(async (entry) => {
    dispatch(loading());
    try {
      const response = await axios.post('/list', entry);
      dispatch(processingRequest(response.data));
    } catch (err) {
      dispatch(handlingError);
    }
  }, []);

  const patchRequest = useCallback(async (id, updated_entry) => {
    dispatch(loading());
    try {
      const response = await axios.patch(`/list/${id}`, updated_entry);
      dispatch(processingRequest(response.data));
    } catch (err) {
      dispatch(handlingError);
    }
  }, []);

  const putRequest = useCallback(async (id, updated_entry) => {
    dispatch(loading());
    try {
      const response = await axios.put(`/list/${id}`, updated_entry);
      dispatch(processingRequest(response.data));
    } catch (err) {
      dispatch(handlingError);
    }
  }, []);

  const deleteRequest = useCallback(async (id) => {
    dispatch(loading());
    try {
      const response = await axios.delete(`/list/${id}`);
      dispatch(processingRequest(response.data));
    } catch (err) {
      dispatch(handlingError);
    }
  }, []);

  return [
    state,
    getRequest,
    postRequest,
    patchRequest,
    putRequest,
    deleteRequest,
  ];
};

export default useApiReq;

Context API

import React, { createContext } from 'react';
import useApiReq from '../components/custom-hooks/useApiReq';

export const AppContext = createContext();

const AppContextProvider = (props) => {
  const [
    state,
    getRequest,
    postRequest,
    patchRequest,
    putRequest,
    deleteRequest,
  ] = useApiReq();

  return (
    <AppContext.Provider
      value={{
        state,
        getRequest,
        postRequest,
        patchRequest,
        putRequest,
        deleteRequest,
      }}
    >
      {props.children}
    </AppContext.Provider>
  );
};

export default AppContextProvider;

App:

import React from 'react';
import AppContextProvider from './context/AppContext';
import Header from './components/header/Header';
import Main from './components/main/Main';
import './stylesheets/styles.scss';

function App() {
  return (
    <AppContextProvider>
      <div className='App'>
        <Header />
        <Main />
      </div>
    </AppContextProvider>
  );
}

export default App;

Main: This is where the GET request happens on initial load.

import React, { useEffect, useContext } from 'react';
import { AppContext } from '../../context/AppContext';
import Sidebar from '../sidebar/Sidebar';
import ParentListItem from '../list-templates/ParentListItem';

function Main() {
  const { state, getRequest } = useContext(AppContext);
  const { isError, isLoading, data } = state;

  useEffect(() => {
    getRequest();
  }, [getRequest]);

  return (
    <main className='App-body'>
      <Sidebar />
      <div className='list-area'>
        {isLoading && (
          <p className='empty-notif'>Loading data from the database</p>
        )}
        {isError && <p className='empty-notif'>Something went wrong</p>}
        {data.length == 0 && <p className='empty-notif'>Database is empty</p>}
        <ul className='parent-list'>
          {data.map((list) => (
            <ParentListItem key={list._id} {...list} />
          ))}
        </ul>
      </div>
    </main>
  );
}

export default Main;

Sidebar

import React, { useState } from 'react';
import Modal from 'react-modal';
import AddList from '../modals/AddList';
import DeleteList from '../modals/DeleteList';

/* Modal */
Modal.setAppElement('#root');

function Sidebar() {
  const [addModalStatus, setAddModalStatus] = useState(false);
  const [deleteModalStatus, setDeleteModalStatus] = useState(false);

  const handleAddModal = () => {
    setAddModalStatus((prevState) => !prevState);
  };

  const handleDeleteModal = () => {
    setDeleteModalStatus((prevState) => !prevState);
  };
  return (
    <aside className='sidebar'>
      <nav className='nav'>
        <button className='btn-rec' onClick={handleAddModal}>
          Add
        </button>
        <button className='btn-rec' onClick={handleDeleteModal}>
          Delete
        </button>
      </nav>
      <Modal isOpen={addModalStatus} onRequestClose={handleAddModal}>
        <header className='modal-header'>Create New List</header>
        <div className='modal-body'>
          <AddList exitHandler={handleAddModal} />
        </div>
        <footer className='modal-footer'>
          <button onClick={handleAddModal} className='btn-circle'>
            &times;
          </button>
        </footer>
      </Modal>
      <Modal isOpen={deleteModalStatus} onRequestClose={handleDeleteModal}>
        <header className='modal-header'>Delete List</header>
        <div className='modal-body'>
          <DeleteList exitHandler={handleDeleteModal} />
        </div>
        <footer className='modal-footer'>
          <button onClick={handleDeleteModal} className='btn-circle'>
            &times;
          </button>
        </footer>
      </Modal>
    </aside>
  );
}

export default Sidebar;

Add Modal This is where the post request is called

import React, { useContext, useEffect, useState, useRef } from 'react';
import { AppContext } from '../../context/AppContext';

const AddList = ({ exitHandler }) => {
  const { postRequest } = useContext(AppContext);
  const [newList, setNewList] = useState({});
  const inputRef = useRef(null);

  /* On load set focus on the input */
  useEffect(() => {
    inputRef.current.focus();
  }, []);

  const handleAddList = (e) => {
    e.preventDefault();
    const new_list = {
      list_name: inputRef.current.value,
      list_items: [],
    };
    setNewList(new_list);
  };

  const handleSubmit = (e) => {
    e.preventDefault();    
    postRequest(newList);
    exitHandler();
  };

  return (
    <form onSubmit={handleSubmit} className='generic-form'>
      <input
        type='text'
        ref={inputRef}
        placeholder='List Name'
        onChange={handleAddList}
      />
      <input type='submit' value='ADD' className='btn-rec' />
    </form>
  );
};

export default AddList;

Delete Modal This is where the Delete Request is called.

import React, { useContext, useEffect, useState, useRef } from 'react';
import { AppContext } from '../../context/AppContext';

const DeleteList = ({ exitHandler }) => {
  const { state, deleteRequest } = useContext(AppContext);
  const { data } = state;
  const selectRef = useRef();
  const [targetListId, setTargetListId] = useState();

  useEffect(() => {
    selectRef.current.focus();
  }, []);

  useEffect(() => {
    setTargetListId(data[0]._id);
  }, [data]);

  const handleDeleteList = (e) => {
    e.preventDefault();
    deleteRequest(targetListId);
    exitHandler();
  };

  const handleChangeList = (e) => {
    setTargetListId(e.target.value);
  };

  return (
    <form onSubmit={handleDeleteList} className='generic-form'>
      <label>
        <select
          ref={selectRef}
          value={targetListId}
          onChange={handleChangeList}
          className='custom-select'
        >
          {data.map((list) => (
            <option key={list._id} value={list._id}>
              {list.list_name}
            </option>
          ))}
        </select>
      </label>
      <input type='submit' value='DELETE' className='btn-rec' />
    </form>
  );
};

export default DeleteList;

Parent List: This is where the PUT, PATCH request is called

import React, { useContext, useState, useEffect, useRef } from 'react';
import { FaPen, FaCheck } from 'react-icons/fa';
import ChildListItem from './ChildListItem';
import { AppContext } from '../../context/AppContext';
import displayDate from '../../utilities/utilities';
import { v4 } from 'uuid';

function ParentListItem({ _id, list_name, list_items }) {
  const { patchRequest, putRequest } = useContext(AppContext);
  const [activeListItems, setActiveListItems] = useState([]);
  const [completedListItems, setCompletedListItems] = useState([]);
  const [listItems, setListItems] = useState({});
  const [disabledInput, setDisabledInput] = useState(true);
  const [title, setTitle] = useState({});
  const [status, setStatus] = useState(false);
  const titleRef = useRef();
  const { day, date, month, year, current_time } = displayDate();

  const handleCreateNewItem = (e) => {
    const newItem = {
      item_id: v4(),
      item_name: e.target.value,
      item_date_created: `${day}, ${date} of ${month} ${year} at ${current_time}`,
      isComplete: false,
    };
    const new_list_items = [...list_items, newItem];
    setListItems({ list_items: new_list_items });
  };

  /* Handles the edit list title button */
  const toggleEdit = () => {
    setDisabledInput(!disabledInput);
  };

  /* Handles the edit list title button */
  const toggleStatus = (item_id) => {
    const target = list_items.find((item) => item.item_id == item_id);
    let updated_list = [...list_items];
    updated_list.map((list) => {
      if (list == target) {
        list.isComplete = !list.isComplete;
      }
    });
    const update = { list_items: updated_list };
    putRequest(_id, update);
  };

  /* Handles the edit list title button */
  const deleteItem = (item_id) => {
    const target = list_items.find((item) => item.item_id == item_id);
    let updated_list = [...list_items].filter((list) => {
      if (target.isComplete == true) {
        return list !== target;
      }
    });
    const update = { list_items: updated_list };
    putRequest(_id, update);
  };

  /* Handles the edit list tile input */
  const handleTitleChange = (e) => {
    const newTitle = { list_name: e.target.value };
    setTitle(newTitle);
  };

  /* Handles the submit or dispatched of edited list tile*/
  const handleUpdateTitle = (e) => {
    e.preventDefault();
    patchRequest(_id, title);
    setDisabledInput(!disabledInput);
  };

  const handleSubmitItem = (e) => {
    e.preventDefault();
    putRequest(_id, listItems);
    [e.target.name] = '';
  };

  useEffect(
    (e) => {
      if (disabledInput === false) titleRef.current.focus();
    },
    [disabledInput]
  );

  useEffect(() => {
    setTitle(list_name);
  }, [list_name]);

  useEffect(() => {
    /* On load filter the active list */
    let active_list_items = list_items.filter(
      (item) => item.isComplete === false
    );
    setActiveListItems(active_list_items);
  }, [list_items]);

  useEffect(() => {
    /* On load filter the completed list */
    let completed_list_items = list_items.filter(
      (item) => item.isComplete === true
    );
    setCompletedListItems(completed_list_items);
  }, [list_items]);

  return (
    <li className='parent-list-item'>
      <header className='p-li-header'>
        <input
          type='text'
          className='edit-input'
          name='newlist'
          ref={titleRef}
          defaultValue={list_name}
          onChange={handleTitleChange}
          disabled={disabledInput}
        />
        {disabledInput === true ? (
          <button className='btn-icon' onClick={toggleEdit}>
            <FaPen />
          </button>
        ) : (
          <form onSubmit={handleUpdateTitle}>
            <button className='btn-icon' type='submit'>
              <FaCheck />
            </button>
          </form>
        )}
      </header>
      <div id={_id} className='p-li-form-container'>
        <form className='generic-form clouds' onSubmit={handleSubmitItem}>
          <input
            type='text'
            placeholder='Add Item'
            name='itemname'
            onChange={handleCreateNewItem}
          />
          <input type='submit' value='+' className='btn-circle' />
        </form>
      </div>
      <div
        className={list_items.length === 0 ? 'p-li-area hidden' : 'p-li-area'}
      >
        <section className='pi-child-list-container'>
          <h6>Active: {activeListItems.length}</h6>
          {activeListItems.length === 0 ? (
            <p className='empty-notif'>List is empty</p>
          ) : (
            <ul className='child-list'>
              {activeListItems.map((list) => (
                <ChildListItem
                  key={list.item_id}
                  {...list}
                  list_id={_id}
                  toggleStatus={toggleStatus}
                  deleteItem={deleteItem}
                />
              ))}
            </ul>
          )}
        </section>
        <section className='pi-child-list-container'>
          <h6>Completed: {completedListItems.length}</h6>
          {completedListItems.length === 0 ? (
            <p className='empty-notif'>List is empty</p>
          ) : (
            <ul className='child-list'>
              {completedListItems.map((list) => (
                <ChildListItem
                  key={list.item_id}
                  {...list}
                  list_id={_id}
                  toggleStatus={toggleStatus}
                  deleteItem={deleteItem}
                />
              ))}
            </ul>
          )}
        </section>
      </div>
    </li>
  );
}

export default ParentListItem;

Child List

import React from 'react';
import { IconContext } from 'react-icons';
import { FaTrashAlt, FaRegCircle, FaRegCheckCircle } from 'react-icons/fa';

function ChildListItem({
  item_name,
  item_id,
  item_date_created,
  isComplete,
  toggleStatus,
  deleteItem,
}) {
  const handleIsComplete = (e) => {
    e.preventDefault();
    toggleStatus(item_id);
  };

  const handleDeleteItem = (e) => {
    e.preventDefault();
    deleteItem(item_id);
  };

  return (
    <li className='c-li-item' key={item_id}>
      <div className='c-li-details'>
        <p className='item-name'>{item_name}</p>
        <p className='date-details'>Date created: {item_date_created}</p>
      </div>
      <div className='c-li-cta'>
        <label htmlFor={item_id} className='custom-checkbox-label'>
          <input
            type='checkbox'
            id={item_id}
            checked={isComplete}
            onChange={handleIsComplete}
          />
          <span className='btn-icon'>
            <IconContext.Provider
              value={{ className: 'react-icon ri-success' }}
            >
              {isComplete === false ? <FaRegCircle /> : <FaRegCheckCircle />}
            </IconContext.Provider>
          </span>
        </label>
        <button
          className='btn-icon btn-delete'
          disabled={!isComplete}
          onClick={handleDeleteItem}
        >
          <IconContext.Provider
            value={{
              className:
                isComplete === false
                  ? 'react-icon ri-disabled'
                  : 'react-icon ri-danger',
            }}
          >
            <FaTrashAlt />
          </IconContext.Provider>
        </button>
      </div>
    </li>
  );
}

export default ChildListItem;
Twirlman
  • 1,109
  • 2
  • 12
  • 30
  • 1
    Please, limit the snippets you share to a [mcve], emphasis on _minimal!_ – Emile Bergeron May 12 '20 at 05:00
  • As you already know the problem here, try find out which component is un mounting and why, you can try logging in useEffect cleanup if not sure. Once you find out if it's necessary to code that way you use a mounted ref to keep track if the component is still mounted and update accordingly. – Rahil Ahmad May 12 '20 at 05:00
  • @RahilAhmad how? I'm not sure how to track it. – Twirlman May 12 '20 at 06:14

1 Answers1

2

The warning occurred because your component received the response but it was already unmounted(stoped rendering)

To fix this you have to cancel the request after the component is unmounted like this using useEffect()'s cleanup function (by return cancel function) and axios like the example below

useEffect( () => { 
    const CancelToken = axios.CancelToken;
    let cancel;
    const callAPI = async () => {
      try {
        let res = await axios.post(`.....`, { cancelToken: new CancelToken(function executor(c) {
          // An executor function receives a cancel function as a parameter
          cancel = c;
        }) });
      }
      catch (err) {
        console.log(err)
      }
    }
    callAPI();
    return (cancel);
  }, []);

you can read more in axios docs: https://github.com/axios/axios However, keep in mind this only solves the warning and not the reason it redirects after your post request.

vmod
  • 61
  • 4