Fetch data manually using useReducer

Viewed 748

I need to fetch data from server using loadData() not only on ComponentDidMount, but also after methods onFiltersClear, onApplyFilters and onPageChange. How could I do that? Do I need some custom data fetching hook here or there are other ways to do that?

import { Table } from "lpcnse-core-front";
import Filters from "./Filters/Filters";
import { styles } from "./checks.css";

const STATUSES_PARENT_ID = "2F24B71C-C70A-4980-9D45-E4134D38E9E0";

const ITEMS_PER_PAGE = 20;

const INITIAL_PAGINATION = {
  pageNumber: 1,
  itemsPerPageCount: ITEMS_PER_PAGE,
  totalCount: 0,
  pageCount: 0
};

const INITIAL_FILTERS = {
  title: "",
  сontrolledObject: "",
  status: null
};

const initialState = {
  filters: INITIAL_FILTERS,
  checks: [],
  pagination: INITIAL_PAGINATION,
  selectedCheckId: null,
  statusList: []
};

const reducer = (state, action) => {
  switch (action.type) {
    case "FETCH_DATA_PENDING":
      return {
        ...state,
        checks: []
      };
    case "FETCH_DATA_SUCCESS":
      const { items, pageNumber, pageCount, totalCount } = action.payload;
      return {
        ...state,
        checks: items,
        pagination: {
          ...state.pagination,
          pageCount,
          totalCount,
          pageNumber
        }
      };
    case "FETCH_DATA_ERROR":
      return {
        ...state,
        checks: [],
        pagination: INITIAL_PAGINATION
      };
    case "SET_FILTER_INPUT":
      return {
        ...state,
        filters: {
          ...state.filters,
          ...action.payload
        }
      };
    case "SET_FILTER_STATE":
      const { status } = action.payload;
      return {
        ...state,
        filters: {
          ...state.filters,
          status
        }
      };
    case "CLEAR_FILTERS":
      return {
        ...state,
        filters: INITIAL_FILTERS
      };
    case "SET_PAGE_NUMBER":
      return {
        ...state,
        pagination: {
          ...state.pagination,
          pageNumber
        }
      };
    case "RESET_PAGINATION":
      return {
        ...state,
        pagination: INITIAL_PAGINATION
      };
    case "FETCH_STATUSES_SUCCESS":
      const { statusList } = action.payload;
      return {
        ...state,
        statusList
      };
    case "FETCH_STATUSES_ERROR":
      return {
        ...state,
        statusList: []
      };
    default:
      throw new Error();
  }
};

export const Checks = props => {
  const [state, dispatch] = useReducer(reducer, initialState);

  const {
    checks,
    pagination: { pageCount, pageNumber },
    filters,
    filters: { title, сontrolledObject, status },
    selectedCheckId,
    statusList
  } = state;

  useEffect(() => {
    loadData();
    loadStatusList();
  }, []);

  const loadData = async () => {
    try {
      const response = await props.api.check.getChecks({
        filters: {
          title,
          сontrolledObject,
          status
        },
        pageNumber,
        maxRowCount: ITEMS_PER_PAGE
      });

      const { items, pageNumber, pageCount, totalCount } = response.data;
      dispatch({
        type: "FETCH_DATA_SUCCESS",
        payload: {
          pageNumber,
          pageCount,
          totalCount,
          items: items || []
        }
      });
    } catch (error) {
      dispatch({
        type: "FETCH_DATA_ERROR"
      });
    }
  };

  const loadStatusList = async () => {
    try {
      const response = await props.api.type.getStatuses({
        parentId: STATUSES_PARENT_ID
      });
      const { items } = response.data;
      dispatch({
        type: "FETCH_STATUSES_SUCCESS",
        payload: {
          statusList: items || []
        }
      });
    } catch (error) {
      dispatch({
        type: "FETCH_STATUSES_ERROR"
      });
    }
  };

  const onFilterInputChange = e => {
    dispatch({
      type: "SET_FILTER_INPUT",
      payload: {
        [e.target.name]: e.target.value
      }
    });
  };

  const onFilterStatusChange = e => {
    dispatch({
      type: "SET_FILTER_STATE",
      payload: {
        status
      }
    });
  };

  const onFiltersClear = () => {
    dispatch({
      type: "CLEAR_FILTERS"
    });

    // loadData
  };

  const onApplyFilters = () => {
    dispatch({
      type: "RESET_PAGINATION"
    });

    // loadData
  };

  const onPageChange = data => {
    dispatch({
      type: "SET_PAGE_NUMBER",
      payload: {
        pageNumber: data.selected + 1
      }
    });

    // loadData
  };

  const onSort = () => {};

  const onOpenUserCard = () => {};

  const columns = [
    //some columns
  ];

  return (
    <div className="checks">
      <Filters
        onFilterInputChange={onFilterInputChange}
        onFilterStatusChange={onFilterStatusChange}
        onApplyFilters={onApplyFilters}
        onClear={onFiltersClear}
        filters={filters}
        statusList={statusList}
      />
      <Table
        columns={columns}
        data={checks}
        onSort={onSort}
        paginated={true}
        onPageChange={onPageChange}
        pageCount={pageCount}
        forcePage={pageNumber}
        selectedItemId={selectedCheckId}
      />

      <style jsx>{styles}</style>
    </div>
  );
};
3 Answers

The key to making the loading functions react to filter changes is to add filters to the useEffect() dependency list.

useEffect(() => {
  loadData();
  loadStatusList();
}, [filters, pageNumber]);

although I don't see loadStatusList() depending on filters, so recommendation is to separate the two async calls into their own effects

useEffect(() => {
  loadData();
}, [filters, pageNumber]);

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

Lastly, the load* functions are getting re-created on every render, so you can move them into the respective effects, or wrap them in useCallback(), or move them outside the component (but then you would have to pass all params instead of using closure).

useEffect(() => {

  const loadData = async () => {
    ...
  });

  loadData();
}, [filters]);

useEffect(() => {

  const loadStatusList = async () => {
    ...
  });

  loadStatusList();
}, []);

I can see from comments that you haven't connected the events (button clicks) and the state changes.

  1. Side effects, particularly async ones must be wrapped in a useEffect().

  2. useEffects are triggered when only state changes (or special case of empty dependency list which === onMount).

  3. Since you can only run effects on state change, you must make the event (button click) change some state, then use that state in the useEffect() dependency list as a trigger.

So, following the onFilterInputChange event

onFilterInputChange 
-> dispatch(SET_FILTER_INPUT) 
-> reducer sets new value for filters
-> effect has filters as dependency, so it runs
-> loadData called with new filters as parameter
-> on response dispatch(FETCH_DATA_SUCCESS) with new data

The only problem we might have is that pagerNumber is an input and an output of the effect, so may get a feedback loop when updating.

To be safe we can add a state variable that controls fetching

const initialState = {
  filters: INITIAL_FILTERS,
  checks: [],
  pagination: INITIAL_PAGINATION,
  selectedCheckId: null,
  statusList: [],
  doFetch: true    // initially true === do a fetch on mount
};

const reducer = (state, action) => {
  switch (action.type) {

    case "FETCH_DATA_PENDING":
      return {
        ...state,
        checks: [],
        doFetch: true
      };    

    case "FETCH_DATA_SUCCESS":
      const { items, pageNumber, pageCount, totalCount } = action.payload;
      return {
        ...state,
        checks: items,
        pagination: {
          ...state.pagination,
          pageCount,
          totalCount,
          pageNumber
        },
        doFetch: false
      };

    case "FETCH_DATA_ERROR":
      return {
        ...state,
        checks: [],
        pagination: INITIAL_PAGINATION,
        doFetch: false
      };
    ...

  const onFilterInputChange = e => {
    dispatch({
      type: "SET_FILTER_INPUT",
      payload: {
        [e.target.name]: e.target.value
      }
    });
    dispatch({type: "FETCH_DATA_PENDING" });
  };

  const onFilterStatusChange = e => {
    dispatch({
      type: "SET_FILTER_STATE",
      payload: {
        status
      }
    });
    dispatch({type: "FETCH_DATA_PENDING" });
  };

  const onFiltersClear = () => {
    dispatch({
      type: "CLEAR_FILTERS"
    });
    dispatch({type: "FETCH_DATA_PENDING" });
  };


useEffect(() => {
  if (doFetch) {
    loadData();
  }
}, [doFetch]);

You can trigger effects manually using refs. This is often useful when using useReducer which cannot fire side effects.

 let fireFiltersClearEffect = React.useRef(false);

 const onFiltersClear = () => {
    dispatch({
      type: "CLEAR_FILTERS"
    });

    fireFiltersClearEffect.current = true;
  };

 React.useEffect(() => {
   if (fireFiltersClearEffect.current !== false) {
      loadData();
      fireFiltersClearEffect.current = false;
   }
  });

Another solution could be to use thunk actions, these actions always have access to most recent state and can dispatch multiple actions. Thunk actions can even call other thunk actions and wait for them (if the thunk action returns a promise): thunkAction(arg)(dispatch,getState).then(thunkActionResult=>otherStuff).

Thunks are well documented and easily recognized by other developers.

Thunk is a middleware of Redux but can be applied to useReducer with a custom hook:

const { useRef, useState } = React;

const compose = (...fns) =>
  fns.reduce((result, fn) => (...args) =>
    fn(result(...args))
  );
const mw = () => (next) => (action) => next(action);
const createMiddleware = (...middlewareFunctions) => (
  store
) =>
  compose(
    ...middlewareFunctions
      .concat(mw)
      .reverse()
      .map((fn) => fn(store))
  );
const useMiddlewareReducer = (
  reducer,
  initialState,
  middleware = () => (b) => (c) => b(c)
) => {
  const stateContainer = useRef(initialState);
  const [state, setState] = useState(initialState);
  const dispatch = (action) => {
    const next = (action) => {
      stateContainer.current = reducer(
        stateContainer.current,
        action
      );
      return setState(stateContainer.current);
    };
    const store = {
      dispatch,
      getState: () => stateContainer.current,
    };
    return middleware(store)(next)(action);
  };
  return [state, dispatch];
};

//middleware
const thunkMiddleWare = ({ getState, dispatch }) => (
  next
) => (action) =>
  typeof action === 'function'
    ? action(dispatch, getState)
    : next(action);
const logMiddleware = ({ getState }) => (next) => (
  action
) => {
  console.log('in log middleware', action, getState());
  Promise.resolve().then(() =>
    console.log('after action:', action.type, getState())
  );
  return next(action);
};

const init = { value: 'A' };
const TOGGLE = 'TOGGLE';
const later = () =>
  new Promise((r) => setTimeout(() => r(), 500));
const otherThunk = () => (dispatch, getState) => {
  dispatch({ type: 'not relevant action form otherTunk' });
  console.log('in other thunk, state is:', getState());
  //note that I am returning a promise
  return later();
};
const thunkToggle = () => (dispatch, getState) => {
  //dispatching other thunk and waiting for it to finish
  otherThunk()(dispatch, getState).then(() =>
    dispatch({ type: TOGGLE })
  );
};
const reducer = (state, { type }) => {
  console.log(`in reducer action type: ${type}`);
  //toggle state.value between A and B
  if (type === TOGGLE) {
    return { value: state.value === 'A' ? 'B' : 'A' };
  }
  return state;
};
const middleware = createMiddleware(
  thunkMiddleWare,
  logMiddleware
);
const App = () => {
  const [state, dispatch] = useMiddlewareReducer(
    reducer,
    init,
    middleware
  );
  return (
    <div>
      <button onClick={() => dispatch(thunkToggle())}>
        toggle
      </button>
      <pre>{JSON.stringify(state, undefined, 2)}</pre>
    </div>
  );
};

ReactDOM.render(<App />, 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>
<div id="root"></div>

Related