Why is useReducer's dispatch causing re-renders?
Asked Answered
K

2

11

Suppose I implement a simple global loading state like this:

// hooks/useLoading.js
import React, { createContext, useContext, useReducer } from 'react';

const Context = createContext();

const { Provider } = Context;

const initialState = {
  isLoading: false,
};

function reducer(state, action) {
  switch (action.type) {
    case 'SET_LOADING_ON': {
      return {
        ...state,
        isLoading: true,
      };
    }
    case 'SET_LOADING_OFF': {
      return {
        ...state,
        isLoading: false,
      };
    }
  }
}

export const actionCreators = {
  setLoadingOn: () => ({
    type: 'SET_LOADING_ON',
  }),
  setLoadingOff: () => ({
    type: 'SET_LOADING_OFF',
  }),
};

export const LoadingProvider = ({ children }) => {
  const [{ isLoading }, dispatch] = useReducer(reducer, initialState);
  return <Provider value={{ isLoading, dispatch }}>{children}</Provider>;
};

export default () => useContext(Context);

Then suppose I have a component that mutates the loading state, but never consumes it, like this:

import React from 'react';
import useLoading, { actionCreators } from 'hooks/useLoading';

export default () => {
  const { dispatch } = useLoading();
  dispatch(actionCreators.setLoadingOn();
  doSomethingAsync().then(() => dispatch(actionCreators.setLoadingOff()))
  return <React.Fragment />;
};

According to useReducer docs, dispatch is has a stable identity. I interpreted this to mean that when a component extracts dispatch from a useReducer, it won't re-render when the state connected to that dispatch changes, because the reference to dispatch will always be the same. Basically, dispatch can "treated like a static value".

Yet when this code runs, the line dispatch(actionCreators.setLoadingOn()) triggers an update to global state and the useLoading hook is ran again and so is dispatch(actionCreators.setLoadingOn()) (infinite re-renders -_-)

Am I not understanding useReducer correctly? Or is there something else I'm doing that might be causing the infinite re-renders?

Koehler answered 31/1, 2020 at 22:38 Comment(4)
doSomethingAsync might be the problem because it is rerunning on every render. In most cases, you'd want to wrap doSomethingAsync with a useEffect(() => {...}, []) to prevent it from rerunning on every render. Same goes for dispatch(actionCreators.setLoadingOn());. If it isn't wrapped in a useEffect, it's going to dispatch setLoadingOn on every render which will cause a rerender. Does this pseduocode correctly match your actual issue, or should this be updated to better match reality with more useEffects?Drucilla
You have a syntax error. setLoadingOn(); does not close a paren.Kim
@Drucilla yeah of course. This component is mainly just for demonstration purposes. The actual doSomethingAsync would be in something like an event handler or a useEffect.Koehler
@Drucilla Perhaps a more realistic a more realistic example would be if this were a button. Maybe something like: onClick={() => dispatch(actionCreators.setLoadingOn())} Details aside, at high level, what we would have is a pure functional component that mutates some state. But according to the rules of hooks, a component like this would re-render on every state change even though it doesn't subscribe to any of the state it mutates. Of course I could use something like useMemo to control this components re-rendering rules, but still. It just seems oddKoehler
E
16

The first issue is that you should never trigger any React state updates while rendering, including useReducers's dispatch() and useState's setters.

The second issue is that yes, dispatching while always cause React to queue a state update and try calling the reducer, and if the reducer returns a new value, React will continue re-rendering. Doesn't matter what component you've dispatched from - causing state updates and re-rendering is the point of useReducer in the first place.

The "stable identity" means that the dispatch variable will point to the same function reference across renders.

Epididymis answered 31/1, 2020 at 22:49 Comment(5)
Definitely, this was more for demo purposes. Perhaps a more realistic a more realistic example would be if this were a button. Maybe something like: onClick={() => dispatch(actionCreators.setLoadingOn())} Details aside, at a high level, what we would have is a pure functional component that mutates some state. But according to the rules of hooks, a component like this would re-render on every state change even though it doesn't subscribe to any of the state it mutates. Of course I could use something like useMemo to control this components re-rendering rules, but still. It just seems oddKoehler
Not sure what you're pointing to as a problem here. Remember that React's default behavior is to always recursively re-render on every state change. There's nothing special about useReducer in that regard - it's just a different mechanism for organizing the state update logic in a component.Epididymis
Yeah I see that now. Thanks. Now I'm curious what advantages useReducer has over useState. When I would implement global state just using useState and Context, I would pass getter and setter callbacks through the global state hook. I thought this was bad practice for components that only use setter callbacks since it would cause needless re-renders. I thought the "identity safe" dispatch would solve this problem, but it doesn't haha.Koehler
The stable identity of dispatch and setters is useful if the child components are attempting to optimize re-renders via prop comparisons (ie, React.memo() and PureComponent). Overall, useReducer is helpful if you have complex state update logic, need to avoid closures that must read the current state to calculate a new one, or want to allow child components to just indicate "some event happened" and keep the logic higher up.Epididymis
"you should never trigger any React state updates while rendering" - React docs mention some (rare) cases, where it can be valid to update state in render. (= getDerivedStateFromProps). Though I could imagine, that pattern will get problematic with concurrent mode.Theatrical
L
8

Besides the fact that you're setting state while rendering as has been pointed out, I think I can shed some light about how to take advantage dispatch's stable identity to avoid unnecessary re-renders like you are expecting.

Your Provider value is an object (value={{ isLoading, dispatch}}). This means the identity of the value itself will change when the context's state changes (for example, when isLoading changes). So even if you have a component where you only consume dispatch like so:

const { dispatch } = useLoading()

The component will re-render when isLoading changes.

If you're at the point where you feel re-rendering is getting out of hand, the way to take advantage of dispatch stable identity is to create two Providers, one for the state (isLoading in this case) and one for dispatch, if you do this, a component that only needs dispatch like so:

const dispatch = useLoadingDispatch()

Will not re-render when isLoading changes.

Note that this can be an overoptimization and in simple scenarios might not be worth it.

This is an excellent set of articles for further reading on the subject: https://kentcdodds.com/blog/how-to-optimize-your-context-value https://kentcdodds.com/blog/how-to-use-react-context-effectively

Lumpen answered 22/8, 2020 at 22:4 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.