March 19

State management in React applications

The issue of state management in React applications has always been very relevant. React itself, when it first came out, did not offer any comprehensive approaches to state management and left this task to the developers. The technical tools available were the classic component properties and the internal state of components. The standard basic concept was extremely primitive and involved storing data in the component's state. Thus, global data could be stored in the state of the top component, while more specific data could be stored in the states of lower components. The only built-in way to exchange data between components was to use component properties.

Prop drilling

The typical approach looks something like this:

class App extends React.Component {
  constructor(props) {
    super(props);

    // initialize the state with default values
    this.state = {
      a: '',
      b: 0,
    };
  }

  render() {
    // pass data from the state to a child component
    return <Child a={this.state.a} b={this.state.b} />;
  }
}

class Child extends React.Component<any, any> {
  // the child component receives data through the reference this.props
  render() {
    return (
      <div>
        <div>{this.props.a}</div>
        <div>{this.props.b}</div>
      </div>
    );
  }
}

ReactDOM.render(<App />, document.getElementById('root'));

In the example above, the data is stored in the state of the parent component and passed to the child component through props. The child component may also have its own internal state.

class Child extends React.Component {
  constructor(props) {
    super(props);

    this.state = {
      c: 2,
    };
  }

  render() {
    return (
      <div>
        <div>{this.props.a}</div>
        <div>{this.props.b}</div>
        <Child2 c={this.state.c} />
      </div>
    );
  }
}

class Child2 extends React.Component {
  render() {
    return (
      <div>
        <div>{this.props.c}</div>
      </div>
    );
  }
}

In this case, changing the component's state is only possible by calling the method this.setState inside the component, meaning the child component can update the parent-level data only if it has the corresponding method passed to it through the same props.

class App extends React.Component {
  constructor(props) {
    super(props);

    this.state = {
      a: '',
      b: 0,
    };
  }

  render() {
    return (
      <Child
        a={this.state.a}
        b={this.state.b}
        setB={(b) => {
          this.setState({ b });
        }}
      />
    );
  }
}

class Child extends React.Component<any, any> {
  constructor(props) {
    super(props);

    this.state = {
      c: 2,
    };
  }

  render() {
    return (
      <div>
        <div>{this.props.a}</div>
        <div>{this.props.b}</div>
        <Child2
          b={this.state.b}
          c={this.state.c}
          setB={this.props.setB}
        />
      </div>
    );
  }
}

class Child2 extends React.Component {
  render() {
    return (
      <div>
        <div>{this.props.c}</div>

        <button
          onClick={() => {
            this.props.setB(this.props.b + 1);
          }}
        >
          Increment
        </button>
      </div>
    );
  }
}

At this stage, the problem of this approach becomes apparent. In real applications with a large number of components, this scheme leads to what is known as prop drilling, which is the mass passing of references down the component tree and back up. This inevitably leads to:

a) Data update race, where two components, unaware of each other, try to update the same reference in the state;

b) Components acting as intermediaries for data they don't actually operate with;

c) Complexity in replacing a component in the middle of the chain;

d) A long and convoluted graph of state references.

Additionally, the task was further complicated by the fact that in the early versions of React, simple functional components did not have internal state (hooks didn't exist back then).

const Child3 = ({ c }) => {
  // A functional component does not have this.state, the useState()
  // hook was introduced only in 2019 starting from version 16.8.0.
  return <div>{c}</div>;
};

Flux

Needless to say, the approach shown above led to a large number of errors and exponentially increasing code complexity. Even the creators of "React" could not avoid this. In the past years, errors related to various asynchronous processes such as disappearing or not displaying notifications and others constantly appeared on the "Facebook" website. In response, developers began to work on a new approach to organizing data management in the application, giving rise to the Flux pattern. The precursor of this pattern was the classic and very popular MVC pattern at the time. The essence of Flux is that data should always move in one direction and be stored in a separate large store. Data updates are carried out using special action methods that are not tied to any specific component.

Based on the Flux pattern, many state management libraries appeared, such as Fluxxor, Flummox, Baobab, and many others. The most popular of them, over the years and to this day, is Redux.

Redux

The application state management library Redux appeared in 2015 and has since gained immense popularity. Redux has proven the effectiveness of the Flux approach and until recently has been considered the standard in the React world.

The architecture of a typical application has taken the following form.

import { connect, Provider } from 'react-redux';

const defaultState1 = {
  a: "",
  b: 0
};

const reducer1 = function(state = defaultState1, action) {
  switch(action.type){
    case "A_TYPE":
      return { ...state, a: action.payload };
    case "B_TYPE":
      return { ...state, b: action.payload };
  }

  return state;
};

const defaultState2 = {
  c: false,
  d: []
};

const reducer2 = function(state = defaultState2, action) {
  switch(action.type){
    case "C_TYPE":
      return { ...state, c: action.payload };
    case "D_TYPE":
      return { ...state, c: action.payload };
  }

  return state;
};

const store = createStore(
  combineReducers({
    reducer1,
    reducer2
  })
);

const App = connect(
  (state) => ({ b: state.reducer1.b }),
  (dispatch) => {
    setB: (value) => dispatch({ type: "B_TYPE", payload: value }) 
  }
)(({ b }) => {
  return (
    <Provider store={store}>
      <button
        onClick={() => {
          setB(b + 1)
        }}
      >
        Increase {b}
      </button>
    </Provider>
  );
});

Now all the data in the application is stored in a separate large tree outside of any component. It is possible to "connect" to the tree, meaning to listen to all the changes in the necessary branch and react to these changes. To update a value in the tree, you can send a so-called action - an event containing an object with the field type. Based on this type, the reducer will understand what kind of action it is and execute the corresponding mutation of the tree.

In general, the issue that seemed to be resolved. Data live separately, components separately. No race or prop drilling. However, the price for this is bulky constructions to ensure the viability of the storage itself. A reducer and action are needed for each property of the tree. Moreover, in more complex applications, as the tree grows, specific cases begin to arise. For example, in deeply nested objects, it becomes harder to maintain immutability, leading to unresponsive reactions to state changes. Additional libraries have come to the help, such as immutable-js and reselect.

Then the issue of working with requests arose, as requests are the main source of data in the application in most cases. This meant that there was a need to add asynchrony to reducers. That's how redux-thunk and redux-saga came about. However, even that was not enough.

Requests, in addition to data, also have their own state. The average application needs to know at least whether the request is still in progress or the response has already been received. Traditionally, the request execution flag (usually called "isFetching" or "isLoading") was stored right in the tree, which required keeping the same mechanism for setting this flag for each request. Along with the loading flag in the same situation was the handling of request errors. The error message was also stored in the tree next to the data and loading flag. Digging deeper, advanced applications wanted to cache responses to requests for optimization purposes to avoid requesting data over the network every time a component is mounted, for example. All these routine operations and optimizations further bloated the already not small code serving the state tree. Therefore, the appearance of libraries like redux-toolkit (RTK) seems quite logical. The RTK library allows organizing work with the state tree in a unified pattern. And in combination with the additional rtk-query, you can get request caching and information on their status out of the box. However, it is still impossible to completely get rid of actions and reducers in this way.

useReducer

"Redux" and other Flux-like libraries are third-party developments. Of course, the "React" team did not stand aside and added the ability to work in a "Flux" style to the API. In fact, this is reflected in the introduction of the useReducer hook in React version 16.8.0.

The same example that was mentioned above can now be implemented without using "Redux."

const reducer = (state, action) => {
  switch (action.type) {
    case 'A_TYPE':
      return { ...state, a: action.payload };
    case 'B_TYPE':
      return { ...state, b: action.payload };

    default:
      return state;
  }
};

export const App = () => {
  const [state, dispatch] = useReducer(reducer, { a: "", b: 0 });

  return (
    <button
      onClick={() => {
        dispatch({ type: "A_TYPE", payload: b + 1 })
      }}
    >
      Increase {state.b}
    </button>
  );
};

However, all the previously mentioned issues related to working with Redux are also relevant to useReducer. Since Redux has grown with a multitude of additional libraries and tools, the hook has not gained much popularity among developers.

React context

The context in React has existed since the earliest versions. However, it was officially included in the API only starting from version 16.3.0 in 2018. The essence of the approach is as follows: data that needs to be made available to multiple components is placed in a separate independent location using the createContext() API method.

const MyContext = createContext({ a: "", b: 0 });

The result of the createContext method is an object containing references to the provider and consumer of the created context.

The provider should wrap a component or a tree of components that will have access to the data inside this context.

class App extends React.Component<any, any> {
  render() {
    return (
      <MyContext.Provider value={{ a: '', b: 0 }}>
        <Child />
      </MyContext.Provider>
    );
  }
}

class Child extends React.Component<any, any> {
  contextType = MyContext;

  render() {
    return (
      <div>
        <div>{this.context.a}</div>
        <Child2 />
      </div>
    );
  }
}

const Child2 = () => {
  return (
    <MyContext.Consumer>
      {(context) => <div>{context.b}</div>}
    </MyContext.Consumer>
  );
};

Access to the context is provided through a consumer link. In a class component, you can bind the context to the component by defining the contextType property. In functional components, you can use a direct reference to the consumer - MyContext.Consumer.

With the official support for context API in functional components, the useContext hook became available.

const Child2 = () => {
  const { b } = useContext(MyContext);

  return (
    <div>{b}</div>
  );
};

The component that created the context is responsible for updating the data in the context.

class App extends React.Component<any, any> {
  const [a, setA] = useState("");
  const [b, setB] = useState(0);

  render() {
    return (
      <MyContext.Provider value={{ a, b }}>
        <Child />
        <button onClick={() => setB(b => b + 1)}>
          Increase {b}
        </button>
      </MyContext.Provider>
    );
  }
}

If you need to delegate the ability to update data to lower-level components, you can also place a reference to the action in the context itself.

class App extends React.Component<any, any> {
  const [a, setA] = useState("");
  const [b, setB] = useState(0);

  render() {
    return (
      <MyContext.Provider value={{ a, b, setB }}>
        <Child />
      </MyContext.Provider>
    );
  }
}

const Child = () => {
  const { b, setB } = useContext(MyContext);

  return (
    <button onClick={() => setB(b => b + 1)}>
      Increase {b}
    </button>
  );
};

This way, you can control the context mutation process and allow modification of only those parts that are needed. Moreover, you can add intermediate actions similar to "Redux middleware" for even greater control over mutations.

class App extends React.Component<any, any> {
  const [a, setA] = useState("");
  const [b, setB] = useState(0);
  
  const increaseB = () => {
    setB((b) => {
      return b < 10 ? b + 1 : b;
    });
  };

  render() {
    return (
      <MyContext.Provider value={{ a, b, increaseB }}>
        <Child />
      </MyContext.Provider>
    );
  }
}

const Child = () => {
  const { b, increaseB } = useContext(MyContext);

  return (
    <button onClick={increaseB}>
      Increase {b}
    </button>
  );
};

Among the advantages of context are:

Flexibility

The context storage is extremely primitive, and the API consists of simple wrapper methods. Therefore, the developer has maximum control at all stages of context design.

Selectivity

Unlike Redux with its global tree, each specific context has the ability to wrap only the necessary components. Conversely, a component can be wrapped in several contexts at once. This allows you to control which data will be available and where.

Uniqueness

In some cases, there may be a need to duplicate the context model. This may be necessary, for example, when working with arrays.

import { connect, Provider } from 'react-redux';

const defaultState = {
  items: [
    { id: 1, name: "item 1" },
    { id: 2, name: "item 2" },
    { id: 3, name: "item 3" },
  ],
  selectedId: undefined,
};

const reducer = function(state = defaultState, action) {
  switch(action.type){
    case "SET_SELECTED_ID_TYPE":
      return {
        ...state,
        selectedId: action.id,
      };
    default:
      return state;
  }

  return state;
};

const store = createStore(reducer);

const SelectedItem = connect(
  (state) => ({ item: state.reducer.items.find(item => item.id === state.selectedId) }),
)(({ item }) => {
  return item ? null : (
    <div>
      Seleceted item: {item?.title}
    </div>
  );
});

const ItemsList = connect(
  (state) => ({
    items: state.reducer.items,
  }),
  (dispatch = {
    setItem: (id) => dispatch({ type: 'SET_SELECTED_ID_TYPE', id }),
  })
)(({ items, setItem }) => {
  return (
    <div>
      {items.map((item) => (
        <button key={item.id} onClick={() => setItem(item.id)}>
          {item.title}
        </button>
      ))}

      <SelectedItem />
    </div>
  );
});

In this example, everything seems fine as long as the ItemsList component is mounted. However, if we unmount it, for example, change the route, and then return to it again, the old value of selectedId will remain in the Redux tree. If we expect to see the default value here rather than the old one, we will have to take care of this separately and add the corresponding logic.

This example is quite simple. In practice, much more complex cases are encountered, which cannot be solved with a simple reset action.

In the case of context, the architecture could look like this, for example.

const ItemContext = createContext();

const SelectedItem: FC = ({ title }) => {
  const { title } = useContext(ItemContext);

  return <div>Selected item: {title}</div>;
};

const ItemsList: FC = () => {
  const [items] = useState([
    { id: 1, name: 'item 1' },
    { id: 2, name: 'item 2' },
    { id: 3, name: 'item 3' },
  ]);

  const [selectedItemId, setSelectedItemId] = useState();

  const item = useMemo(
    () => items.find((item) => item.id === selectedItemId),
    [items, selectedItemId]
  );

  return (
    <div>
      {items.map((item) => (
        <button key={item.id} onClick={() => setSelectedItemId(item.id)}>
          {item.title}
        </button>
      ))}

      {item && (
        <ItemContext.Provider value={item}>
          <SelectedItem />
        </ItemContext.Provider>
      )}
    </div>
  );
};

Since the data lives inside the ItemsList component, upon its remount, the state will always have the original appearance, and we do not need to worry additionally about its reset.

Meanwhile, the SelectedItem component operates within the ItemContext and even has no idea where and how the data has placed there.

Universal pattern of using React context

Below, I will present a universal template for typical usage of context in a real application. Like Redux, contexts work well with TypeScript, so I will provide an example with TS typing.

To begin with, let's create an independent context and a separate provider component for it.

// MyContext.tsx
import React, {
  createContext,
  Dispatch,
  FC,
  PropsWithChildren,
  SetStateAction,
  useCallback,
  useContext,
  useMemo,
  useState,
} from 'react';

export interface MyContextProps {
  myVar: string;
  setMyVar: Dispatch<SetStateAction<MyContextProps['myVar']>>;

  someAction: (a: number, b?: boolean) => void;
}

export const MyContext = createContext<MyContextProps>({
  myVar: '',
  setMyVar: () => {},

  someAction: () => {},
});

// instead of using WithMyContext, you can use a more traditional
// name - MyContextProvider
export const WithMyContext: FC<PropsWithChildren> = ({ children }) => {
  const [myVar, setMyVar] = useState<MyContextProps['myVar']>('');

  const someAction = useCallback<MyContextProps['someAction']>(
    (a, b) => {},
    []
  );

  const value = useMemo<MyContextProps>(
    () => ({
      myVar,
      setMyVar,

      someAction,
    }),
    [myVar, someAction]
  );

  return <MyContext.Provider value={value}>{children}</MyContext.Provider>;
};

// let's also create a separate hook for convenience.
export const useMyContext = () => {
  return useContext(MyContext);
};

The context is ready, now we just need to wrap the necessary component in it.

<WithMyContext>
  <MyComponent />
</WithMyContext>

Or, for instance, a specific route in "react-router".

<Route element={(
  <WithMyContext>
    <Outlet />
  </WithMyContext>
)}>
  <Route …>
</Route>

Now the context is available for use within nested components.

import { FC } from 'react';

import { useMyContext } from ‘./MyContext';

export const MyComponent: FC = () => {
  const { myVar } = useMyContext();

  return <div>My component {myVar}</div>;
};


My telegram channels:

EN - https://t.me/frontend_almanac
RU - https://t.me/frontend_almanac_ru

Русская версия: https://blog.frontend-almanac.ru/react-state-management