Search code examples
reactjsredux

Why creating a custom hook always triggers re-renderings and using useDispatch and useSelector directly do not?


There is a pattern I was using on a project where most of the state resides on redux. I also try to keep the application logic close to redux, so I use selectors and I wrap all the possible actions to be taken into custom hooks.

Running some profiling tests I noticed that, when I use my custom hooks (example below) all the components that use them re-render when the redux state changes:

export const useDefinitionForm = () => {
  const { description, step } = useAppSelector((state) => {
    const { name, color, icon, duration } = state.descriptionForm;
    return { description: { name, color, icon, duration }, step: state.descriptionForm.step };
  });

  const dispatch = useAppDispatch();

  return { description, step, actions: bindActionCreators(actions, dispatch) };
};

However, when I use the useDispatch and useSelector hooks directly on the target component, then those components only re-render when the specific redux section changes:

const DurationSection = () => {
  const dispatch = useAppDispatch();
  const duration = useAppSelector((state) => state.descriptionForm.duration);
  return (
    <Section title="Expected duration" hint={duration} step="duration">
      <Typography variant="subtitle1">How much will this task usually last?</Typography>
      <DurationSlider
        value={duration}
        onChange={(value) => dispatch(setDuration(value))}
        valueLabelDisplay="on"
      />
    </Section>
  );
};

Why is this? How can I change the custom hooks so they are as performant as using the hooks directly on the component? I try to keep the components as dumb as possible.


Solution

  • The selector in your custom hook returns a new object every time:

    export const useDefinitionForm = () => {
      const { description, step } = useAppSelector((state) => {
        const { name, color, icon, duration } = state.descriptionForm;
        return { description: { name, color, icon, duration }, step: state.descriptionForm.step };
      });
    
      const dispatch = useAppDispatch();
      //you are returning a new object here every time
      return { description, step, actions: bindActionCreators(actions, dispatch) };
    };
    

    The selector in your other example does not:

    const duration = useAppSelector(
      (state) => 
        //you are only returning a value from state here
        state.descriptionForm.duration
    );
    

    All functions you pass to useSelecor will be executed every time the state changed and if that function returns a changed value your component will re render.

    You can use reselect to memoize the result of previous selectors and only return a new object if any of the re used selector(s) return a changed value:

    const selectDescriptionForm = state => state.descriptionForm;
    //you can create selectors to selectStep, selectDuration, selectIcon ...
    //  and use those instead if this still causes needless re renders
    const selectDuration = createSelector(
      [selectDescriptionForm],
      //the following function will only be executed if
      //  if selectDescriptionForm returns a changed value
      ({ name, color, icon, duration, step })=>({
        description: { 
          name, color, icon, duration 
        }, step
      })
    )
    export const useDefinitionForm = () => {
      const { description, step } = useAppSelector(selectDuration);
      const dispatch = useAppDispatch();
      //making sure actions is not needlessly re created
      const actions = useMemo(
        ()=>bindActionCreators(actions, dispatch),
        [dispatch]
      )
      return { description, step, actions };
    };
    

    Here is an updated version selecting each item of description form you need and if any of the items change will re create the component data:

    const selectDescriptionForm = (state) =>
      state.descriptionForm;
    const selectName = createSelector(
      [selectDescriptionForm],
      (descriptionForm) => descriptionForm.name
    );
    const selectColor = createSelector(
      [selectDescriptionForm],
      (descriptionForm) => descriptionForm.color
    );
    const selectIcon = createSelector(
      [selectDescriptionForm],
      (descriptionForm) => descriptionForm.icon
    );
    const selectDuration = createSelector(
      [selectDescriptionForm],
      (descriptionForm) => descriptionForm.duration
    );
    const selectStep = createSelector(
      [selectDescriptionForm],
      (descriptionForm) => descriptionForm.step
    );
    const selectCompoentData = createSelector(
      [
        selectName,
        selectColor,
        selectIcon,
        selectDuration,
        selectStep,
      ],
      //the following function will only be executed if
      //  any of the functions in the previous list returns
      //  a changed value
      (name, color, icon, duration, step) => ({
        description: {
          name,
          color,
          icon,
          duration,
        },
        step,
      })
    );
    export const useDefinitionForm = () => {
      const { description, step } = useAppSelector(
        selectCompoentData
      );
      const dispatch = useAppDispatch();
      //making sure actions is not needlessly re created
      const actions = useMemo(
        () => bindActionCreators(actions, dispatch),
        [dispatch]
      );
      return { description, step, actions };
    };