3
votes

I'm using react context & axios interceptors to hide/show a loading spinner. While it does hide and show correctly when requests and responses are fired, my loading spinner is only showing at the start of a network request. The request fires and then goes into a 'pending' status. During the 'pending' status, the response interceptor is fired and hides the loading spinner.

How can I make sure the loading spinner stays visible during the pending requests?

I tried adding some console logs to fire when the requests, responses, and errors were returned including the count, and it showed it (for two requests) to successfully go from 0 - 1 - 2 - 1 - 0, but in the chrome devtools network tab showed as pending even though all requests were returned.

EDIT: thought I had it working after some refactor but it was a no-go. Added updated code

import React, { useReducer, useRef, useEffect, useCallback } from "react";
import { api } from "api/api";
import LoadingReducer from "reducer/LoadingReducer";

const LoadingContext = React.createContext();

export const LoadingProvider = ({ children }) => {
  const [loader, dispatch] = useReducer(LoadingReducer, {
    loading: false,
    count: 0,
  });

  const loaderKeepAlive = useRef(null),
    showLoader = useRef(null);

  const showLoading = useCallback(() => {
    dispatch({
      type: "SHOW_LOADING",
    });
  }, [dispatch]);

  const hideLoading = useCallback(() => {
    loaderKeepAlive.current = setTimeout(() => {
      dispatch({
        type: "HIDE_LOADING",
      });
    }, 3000);
    return clearTimeout(loaderKeepAlive.current);
  }, [dispatch]);

  const requestHandler = useCallback(
    (request) => {
      dispatch({ type: "SET_COUNT", count: 1 });
      return Promise.resolve({ ...request });
    },
    [dispatch]
  );

  const errorHandler = useCallback(
    (error) => {
      dispatch({ type: "SET_COUNT", count: -1 });
      return Promise.reject({ ...error });
    },
    [dispatch]
  );

  const successHandler = useCallback(
    (response) => {
      dispatch({ type: "SET_COUNT", count: -1 });
      return Promise.resolve({ ...response });
    },
    [dispatch]
  );

  useEffect(() => {
    if (loader.count === 0) {
      hideLoading();
      clearTimeout(showLoader.current);
    } else {
      showLoader.current = setTimeout(() => {
        showLoading();
      }, 1000);
    }
  }, [showLoader, showLoading, hideLoading, loader.count]);

  useEffect(() => {
    if (!api.interceptors.request.handlers[0]) {
      api.interceptors.request.use(
        (request) => requestHandler(request),
        (error) => errorHandler(error)
      );
    }
    if (!api.interceptors.response.handlers[0]) {
      api.interceptors.response.use(
        (response) => successHandler(response),
        (error) => errorHandler(error)
      );
    }
    return () => {
      clearTimeout(showLoader.current);
    };
  }, [errorHandler, requestHandler, successHandler, showLoader]);

  return (
    <LoadingContext.Provider
      value={{
        loader,
      }}
    >
      {children}
    </LoadingContext.Provider>
  );
};

export default LoadingContext;

1
Ah typo, thank you! I don't believe this would be the issue as the loader keep alive is just to make sure it doesn't flash on and off but instead keep it visible for a bit longer - Almost_Ashleigh

1 Answers

0
votes

I think a more standard approach would be to just utilize the loading state to conditionally render the Spinner and the result of the promise to remove it from the DOM (see demo below). Typically, the interceptors are used for returning the error from an API response, since axios defaults to the status error (for example, 404 - not found).

For example, a custom axios interceptor to display an API error:

import get from "lodash.get";
import axios from "axios";

const { baseURL } = process.env;

export const app = axios.create({
  baseURL
});

app.interceptors.response.use(
  response => response,
  error => {
    const err = get(error, ["response", "data", "err"]);

    return Promise.reject(err || error.message);
  }
);

export default app;

Demo

Edit Conditional Rendering

Code

App.js

import React, { useEffect, useCallback, useState } from "react";
import fakeApi from "./api";
import { useAppContext } from "./AppContext";
import Spinner from "./Spinner";

const App = () => {
  const { isLoading, error, dispatch } = useAppContext();
  const [data, setData] = useState({});

  const fetchData = useCallback(async () => {
    try {
      // this example uses a fake api
      // if you want to trigger an error, then pass a status code other than 200
      const res = await fakeApi.get(200);
      setData(res.data);
      dispatch({ type: "loaded" });
    } catch (error) {
      dispatch({ type: "error", payload: error.toString() });
    }
  }, [dispatch]);

  const reloadData = useCallback(() => {
    dispatch({ type: "reset" });
    fetchData();
  }, [dispatch, fetchData]);

  useEffect(() => {
    fetchData();

    // optionally reset context state on unmount
    return () => {
      dispatch({ type: "reset" });
    };
  }, [dispatch, fetchData]);

  if (isLoading) return <Spinner />;
  if (error) return <p style={{ color: "red" }}>{error}</p>;

  return (
    <div style={{ textAlign: "center" }}>
      <pre
        style={{
          background: "#ebebeb",
          margin: "0 auto 20px",
          textAlign: "left",
          width: 600
        }}
      >
        <code>{JSON.stringify(data, null, 4)}</code>
      </pre>
      <button type="button" onClick={reloadData}>
        Reload
      </button>
    </div>
  );
};

export default App;

AppContext.js

import React, { createContext, useContext, useReducer } from "react";

const AppContext = createContext();

const initialReducerState = {
  isLoading: true,
  error: ""
};

const handleLoading = (state, { type, payload }) => {
  switch (type) {
    case "loaded":
      return { isLoading: false, error: "" };
    case "error":
      return { isLoading: false, error: payload };
    case "reset":
      return initialReducerState;
    default:
      return state;
  }
};

export const AppContextProvider = ({ children }) => {
  const [state, dispatch] = useReducer(handleLoading, initialReducerState);

  return (
    <AppContext.Provider
      value={{
        ...state,
        dispatch
      }}
    >
      {children}
    </AppContext.Provider>
  );
};

export const useAppContext = () => useContext(AppContext);

export default AppContextProvider;

Spinner.js

import React from "react";

const Spinner = () => <div className="loader">Loading...</div>;

export default Spinner;

fakeApi.js

const data = [{ id: "1", name: "Bob" }];

export const fakeApi = {
  get: (status) =>
    new Promise((resolve, reject) => {
      setTimeout(() => {
        status === 200
          ? resolve({ data })
          : reject(new Error("Unable to locate data."));
      }, 2000);
    })
};

export default fakeApi;

index.js

import React from "react";
import ReactDOM from "react-dom";
import AppContextProvider from "./AppContext";
import App from "./App";
import "./styles.css";

const rootElement = document.getElementById("root");
ReactDOM.render(
  <React.StrictMode>
    <AppContextProvider>
      <App />
    </AppContextProvider>
  </React.StrictMode>,
  rootElement
);