1
votes

I have build this generic dropdown/select component with an async function to get datasets. For some reason I get the message The 'fetchData' function makes the dependencies of useEffect Hook (at line 48) change on every render. To fix this, wrap the definition of 'fetchData' in its own useCallback() Hook.eslintreact-hooks/exhaustive-deps.

I don't change any value of a dependency from my useEffect hook since these properties are controlled by my redux slice....

Select component:

import React, { useEffect } from 'react';
import { Form, Select, Typography } from 'antd';
import PropTypes from 'prop-types';
import styled from 'styled-components';

const StyledSelect = styled(Select)`
  &.ant-select-loading .ant-select-selection-item {
    display: none;
  }
`;

const { Text } = Typography;

const CustomSelect = ({
  endpointKey,
  dataKey,
  customLabel,
  required = false,
  dataSet,
  fetchDataSet,
  disabled = false,
  fullOptionHeight = false,
  onChange = null,
  showSearch = false,
  setLoading = null,
}) => {
  const fetchData = async (searchText) => {
    if (setLoading) {
      setLoading(true);
    }
    await fetchDataSet({ endpointKey, searchText });
    if (setLoading) {
      setLoading(false);
    }
  };

  useEffect(() => {
    const dataSetPresent = !!dataSet.data && !!Object.keys(dataSet.data).length;
    const hasError = dataSet.errorMessage && dataSet.errorMessage.length;
    if (
      !dataSetPresent &&
      dataSet.loadingStatus !== 'loading' &&
      dataSet.loadingStatus !== 'loaded' &&
      !hasError
    ) {
      fetchData();
    }
  }, [fetchData, dataSet]);

  const { loadingStatus, data, errorMessage } = dataSet;

  const label = customLabel || endpointKey;
  const formErrorMessage = 'Please select ' + label.toLowerCase();
  const placeholder = `-- Select a ${label.toLowerCase()} --`;

  const renderSelect = () => {
    if (errorMessage !== '') {
      return <Text type="danger">{errorMessage}</Text>;
    }
    return (
      <StyledSelect
        disabled={loadingStatus === 'loading' || disabled}
        loading={loadingStatus === 'loading'}
        placeholder={placeholder}
        optionFilterProp="children"
        style={{ maxWidth: '500px' }}
        size="large"
        onChange={onChange}
        showSearch={showSearch}
        onSearch={(value) => {
          if (showSearch) {
            fetchData(value);
          }
        }}
      >
        {Object.keys(data).map((dataObject) => {
          return (
            <Select.Option
              className={`${fullOptionHeight ? 'full-option-height' : ''}`}
              value={dataObject}
              key={dataObject}
            >
              {data[dataObject]}
            </Select.Option>
          );
        })}
      </StyledSelect>
    );
  };

  return (
    <Form.Item
      label={label}
      name={dataKey}
      rules={[{ required, message: formErrorMessage }]}
    >
      {renderSelect()}
    </Form.Item>
  );
};

CustomSelect.propTypes = {
  endpointKey: PropTypes.string.isRequired,
  dataKey: PropTypes.oneOfType([PropTypes.array, PropTypes.string]),
  customLabel: PropTypes.string,
  required: PropTypes.bool,
  fetchDataSet: PropTypes.func,
  showSearch: PropTypes.bool,
  dataSet: PropTypes.object,
  disabled: PropTypes.bool,
  fullOptionHeight: PropTypes.bool,
  onChange: PropTypes.func,
  setLoading: PropTypes.func,
};

export default CustomSelect;

React Slice with the async hook and state change handling:

import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import callApi from 'utils/api';

export const SELECT_KEY = 'select';

export const fetchDataSet = createAsyncThunk(
  'select/request-data',
  async ({ endpointKey, searchText }) => {
    const endpoint = `data/${endpointKey}`;
    try {
      const { data } = await callApi({
        endpoint,
      });
      return data;
    } catch (error) {
      console.error('ERROR', error);
      throw error;
    }
  }
);

export const selectSlice = createSlice({
  name: SELECT_KEY,
  initialState: {},
  reducers: {},
  extraReducers: {
    [fetchDataSet.pending]: (state, action) => {
      const key = action.meta.arg.endpointKey;
      return {
        ...state,
        [key]: {
          loadingStatus: 'loading',
          errorMessage: '',
          data: {},
        },
      };
    },
    [fetchDataSet.fulfilled]: (state, action) => {
      const key = action.meta.arg.endpointKey;
      return {
        ...state,
        [key]: {
          loadingStatus: 'loaded',
          errorMessage: '',
          data: action.payload,
        },
      };
    },
    [fetchDataSet.rejected]: (state, action) => {
      const key = action.meta.arg.endpointKey;
      return {
        ...state,
        [key]: {
          loadingStatus: 'error',
          errorMessage: action.error.message,
          data: {},
        },
      };
    },
  },
});

export const selectReducer = selectSlice.reducer;

export const dataSetSelector = (state, key) => state.select[key] || {};
1

1 Answers

2
votes

You do actually change a value of a dependency each render cycle since you redeclare fetchData each cycle. The warning is suggesting you simply memoize this callback function so you provide a stable reference for the effect hook.

const fetchData = useCallback(async (searchText) => {
  if (setLoading) {
    setLoading(true);
  }
  await fetchDataSet({ endpointKey, searchText });
  if (setLoading) {
    setLoading(false);
  }
}, [endpointKey]); // <-- add any other necessary dependencies for the callback