9

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?

adrayv
  • 359
  • 1
  • 3
  • 8
  • `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 `useEffect`s? – Adam Jan 31 '20 at 22:41
  • You have a syntax error. `setLoadingOn();` does not close a paren. – evolutionxbox Jan 31 '20 at 22:42
  • @Adam 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. – adrayv Jan 31 '20 at 23:28
  • @Adam 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 odd – adrayv Jan 31 '20 at 23:39

2 Answers2

13

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.

markerikson
  • 63,178
  • 10
  • 141
  • 157
  • 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 odd – adrayv Jan 31 '20 at 23:41
  • 1
    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. – markerikson Feb 03 '20 at 00:05
  • 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. – adrayv Feb 03 '20 at 17:34
  • 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. – markerikson Feb 03 '20 at 17:42
  • "you should *never* trigger any React state updates while rendering" - React docs [mention some (rare) cases](https://reactjs.org/docs/hooks-faq.html#how-do-i-implement-getderivedstatefromprops), where it can be valid to update state in render. (= `getDerivedStateFromProps`). Though I could imagine, that pattern will get problematic with concurrent mode. – ford04 Apr 11 '20 at 08:58
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

G Gallegos
  • 609
  • 6
  • 11