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.


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 };
};