Redux vs Context vs Local Component State - state management solutions for React

Local Component State

The simplest way to store data in React is to keep it in a component. This can be achieved through the useState hook for functional components or setState for class-based components.

function MyComponent() {
const [isOpen, setIsOpen] = useState(false);

function toggleIsOpen() {
setIsOpen(!isOpen);
}

return (
<div>
<button onClick={toggleIsOpen} type="button">Toggle</button>
<p>{isOpen ? "open" : "closed"}</p>
</div>
);
}

The above snippet illustrates a toggle that flips its boolean value after the button is clicked. At first glance, it does not seem to be anything wrong with this approach, and for the most part, it is true. This is a good solution for simple state management. It could even be used exclusively if the application’s architecture was known in advance. In practice, however, projects’ scopes change often result in a failure to adapt to changes.

Separation of data and presentation

The main concern with the local component state strategy is the lack of separation of data and presentation. This poses a problem with a flexibility that can be illustrated with the following code.

First of all, a state has to be defined to keep the input text, and the data to be fetched. This is a local state that is accessible only by this component since neither its current state nor update functions get passed by props anywhere.

function MyComponent() {
const [query, setQuery] = useState("");
const [data, setData] = useState([]);
}

Secondly, a fetch function has to be defined to retrieve the data. This function also assigns the fetched results to the local state using setData.

function fetchData(searchQuery) {
fetch(`https://some-url.com/search?query=${searchQuery}`)
.then((data) => data.json())
.then((data) => setData(data));
}

For convenience, a new function will be created to handle the form submission. This will prevent the page from refreshing and call the fetching function defined above.

function handleSubmit(event) {
event.preventDefault();
fetchData(query);
}

Finally, the code to render the necessary elements will be added to allow for interaction with the above functions.

return (
<div>
<form onSubmit={handleSubmit}>
<label>
Search:
<input
type="text"
value={query}
onChange={(ev) => setQuery(ev.target.value)}
/>
</label>
<input value="Submit" type="submit" />
</form>
<ul>
{data.map((element) => (
<li key={element.id}>{element.id}</li>
))}
</ul>
</div>
);

This code snippet renders an input and a button. Upon entering the query and submitting the form, the data is fetched and displayed in an unordered list.

The problem with the lack of separation of data and presentation, illustrated above, is that the state is tied to what is displayed on the screen. Suppose the code fragment responsible for rendering the input and the button had to be moved to another place in the application. Not only would these two elements have to be moved, but also the state, state setters, the fetching function, and the submit function too. This could prove to be cumbersome in larger projects.

Common Data

Another obstacle you meet when using the local state method is that of multiple components requiring common data. Building upon the previous example, suppose there was another sibling component that wanted to access the fetched data. Assuming the current structure, it would be impossible to obtain the same information by the sibling. The state would have to be lifted to a common parent component. Similarly to the previous case, a few blocks of code responsible for data fetching and management would have to be moved to the parent component. The render function of the parent component would have to return both children

return (
<div>
<ChildOne
handleSubmit={handleSubmit}
query={query}
setQuery={setQuery}
data={data}
setData={setData}
/>
<ChildTwo query={query} data={data} />
</div>
);

The above code is a fragment of the parent component that renders two siblings, passing similar props to both, preserving the principle in which the data flows down. This is a decent solution, however, this process can lead to keeping unintended data in non-related components. A situation could occur in which the parent component would only serve as a container for the data, and only pass it down to its children while not making any use of it for itself.

Redux

A very common way of storing data in React apps is to keep it in a store. There are many libraries that facilitate state management, however, the most popular one is Redux. The main idea behind Redux is to have a single store for data that can be changed only through actions. The store sits at the top of the application and is accessible from anywhere. To illustrate what a typical application would look like, the example from the previous paragraph could be used.

Actions

The only way to change data in a Redux store is to dispatch an action. To dispatch an action, a function called dispatch can be used, however, a very common approach is to use an additional library that provides a hook called useDispatch. An action is an object that contains at least one key-value pair which is its type. A simple action containing information about the fetched data could look like the this:

function fetchDataSuccess(data) {
return { type: FETCH_DATA_SUCCESS, payload: data };
}

Actions are then intercepted by reducers for further processing.

Reducer

Reducer is a function that decides how to process incoming data. Each reducer must not directly modify its state and instead return a new, fresh version of it in order to comply with the principle of immutability. The below snippet returns an object depending on an action that was dispatched.

const defaultState = { data: [], hasError: false };

function dataReducer(state = defaultState, action) {
switch (action.type) {
case FETCH_DATA_SUCCESS:
return { data: action.payload, hasError: false };
case FETCH_DATA_ERROR:
return { ...state, hasError: true };
default:
return state;
}
}

It accepts a state and an action as parameters. While action has certain restrictions on its structure, the state could be of any data type. In the above example, a state is an object containing two key-value pairs. One of them is a data array and the other is an error flag.

Store

At the heart of each React/Redux application is a store. After the corresponding reducer is done processing its data it then passes it to the store. The store contains a global state, accessible by any component. To create a store, a function called createStore must be used. The entire React application has to be then wrapped with that store.

Complexity

Redux seems to be overly complex for managing state. There are actions (usually action creators too), reducers, and the store. The amount of extra code that has to be written is intimidating to new developers. All these steps seem like a lot of extra work when compared to the local state approach. However, there are helper libraries that facilitate the process of interaction with Redux such as redux-toolkit.

The main disadvantage of using Redux is that a few additional packages have to be installed to make full use of this state management system. For Redux to work, a package named redux has to be installed. Most projects also use other libraries to facilitate its usages, such as react-redux, redux-thunk, redux-devtools, redux-actions, reselect, normalizr, redux-persist, redux-mock-store, and more. This further increases the complexity of the application, learning curve for developers, and the bundle size.

The larger the size of the packages, the longer it takes to load pages. Below are the size statistics for the most popular redux plugins, as well as redux itself. In the following list each package specifies their minified and compressed sizes:

  • redux: 7.3 kB (minified), 2.6 kB (gzipped)
  • react-redux: 14.4 kB (minified), 4.9 kB (gzipped)
  • redux-thunk: 352 B (minified), 236 B (gzipped)
  • redux-devtools (assuming production use): 36.6 kB (minified), 11.3 kB (gzipped)
  • redux-actions: 7 kB (minified), 2.7 kB (gzipped)
  • Reselect: 1.9 kB (minified), 828 B (gzipped)
  • Normalizr: 7 kB (minified), 2.2 kB (gzipped)
  • Redux-persist: 10.2 kB (minified), 3 kB (gzipped)

The size of all of the above packages, used in a typical project, sums up to about 84.75 kB (minified) or 27.76 kB (gzipped). The overall download time would amount to an additional 938 ms of load time for 2G network users and 565 ms for 3G users.

Context

While Redux is a package-based way of storing the data globally, there is also another technique of doing it in pure React. An alternative way to store data globally is to keep it in a Context. It allows the creation of multiple stores of data that can be shared by components. Context provides a way to pass data through the component tree without passing props down manually at each nesting level.

Much like in Redux, Context is based on a store that provides data. The only difference is that the Context API provides a way to create multiple stores. To create a store, a function named createContext has to be used.

const DataContext = createContext();

const DataContextProvider = ({ children }) => {
const [data, setData] = useState([]);

function fetchData(searchQuery) {
fetch(`https://some-url.com/search?query=${searchQuery}`)
.then(data => data.json())
.then(data => setData(data));
}

return (
<DataContext.Provider value={{ data, fetchData }}>
{children}
</DataContext.Provider>
);
};

The above code creates a Context responsible for fetching and saving data. This Context contains a consumer that will be used to make use of its data and a provider that will act as a store. Same as in Redux, it has to wrap components that will access its state.

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

The difference between the Context approach is that it is acceptable to use the provider anywhere. It does not have to wrap the entire application. There can be multiple sources of truth in an application.

The value supplied by the provider can be of any type. In this case, the state and the fetching functions were provided to ensure some restrictions on interaction with the internal state.

const { data, fetchData } = useContext(DataContext);

The data can be accessed anywhere in the hierarchy by using the useContext hook. The props do not have to be passed down through each component, they can be accessed from multiple levels down.

Preventing prop drilling

The main idea behind context is to use it whenever data needs to be accessed by many components at different nesting levels. It is however incorrectly used for the sole purpose of preventing passing props through multiple components (prop drilling).

<Page user={user} avatarSize={avatarSize} />
// nesting level 1
<PageLayout user={user} avatarSize={avatarSize} />
// nesting level 2
<NavigationBar user={user} avatarSize={avatarSize} />
// nesting level 3
<Link href={user.permalink}>
<Avatar user={user} size={avatarSize} />
</Link>

If a situation arises, where multiple props have to be passed down just to apply it to a specific component, it is advisable to pass the entire component to render as a prop.

unction Page(props) {
const user = props.user;
const userLink = (
<Link href={user.permalink}>
<Avatar user={user} size={props.avatarSize} />
</Link>
);
return <PageLayout userLink={userLink} />;
}

<Page user={user} avatarSize={avatarSize} />
// nesting level 1
<PageLayout userLink={userLink} />
// nesting level 2
<NavigationBar userLink={userLink} />
// nesting level 3
{props.userLink}

The code fragment responsible for rendering the link and an avatar has been replaced with a prop, which is a component defined at the very top. While this approach does not prevent the necessity to pass props down multiple levels, it at least helps in reducing the number of props that have to be forwarded.

Redux vs Context

There are many similarities between Redux and the Context API. Context looks very appealing in its ease of use compared to Redux, however, there are some advantages that Redux has over the Context API.

Making context more like Redux

There are three principles of Redux:

  • Single source of truth
  • State is read-only
  • Changes are made with pure functions

The reason for having a single store is to make an application more predictable and easier to save and read its state upon a page refresh. To achieve the first principle, the store created by the context could simply wrap the entire application at the root level. There must also be a way of saving the data to the localStorage. A lifecycle method or a hook that persists its data would suffice. The second principle dictates that the state should be read-only and can be changed by dispatching an action. The state being read-only could be achieved by switching to more restrictive setter functions. To borrow from Redux, a reducer could be used, which is achieved by using the useReducer hook. The last principle states that changes are made with pure functions. By using reducers with the help of the hook mentioned in the previous paragraph, this rule can be fulfilled. The exact same reducers used in Redux can be used with the useReducer hook for the Context.

To illustrate the last principle, suppose the same reducer is used as in the Redux section. Now the line responsible for storing the data

const [data, setData] = useState([]);

could be replaced with

const [data, dispatch] = useReducer(reducer, defaultState);

Now dispatch is responsible for dispatching actions that are then handled by their corresponding reducers.

function fetchData(searchQuery) {
return function(dispatch) {
dispatch(fetchDataRequest());

return fetch(
`https://some-url.com/search?query=${searchQuery}`
)
.then(data => data.json())
.then(data => dispatch(fetchDataSuccess(data)))
.catch(error => dispatch(fetchDataError(error)));
};
}

Just like in Redux, an action creator would have to be implemented for asynchronous actions. To make use of the new changes introduced to the context so far, the following lines

const DataContextProvider = ({ children }) => {
const [data, setData] = useState([]);

return (
<DataContext.Provider value={{ data, fetchData }}>
{children}
</DataContext.Provider>
);
};

have to be changed to:

const DataContextProvider = ({ children }) => {
const [data, dispatch] = useReducer(reducer, defaultState);

return (
<DataContext.Provider value={{ data, dispatch }}>
{children}
</DataContext.Provider>
);
};

Once the dispatch function is provided, it can be used in a component to dispatch actions by changing this line:

const { data, fetchData } = useContext(DataContext);

into this line:

const { data, dispatch } = useContext(DataContext);

Finally, to make use of the function creator called fetchData, the information could be requested using the dispatch function.

function handleSubmit(event) {
event.preventDefault();
fetchData(query)(dispatch);
}

For regular, synchronous actions the dispatch would look simpler. All that is required is the action that accepts optional arguments.

dispatch(incrementCounter());

Where Context shines

Context has its advantages - it does not require the entire boilerplate of Redux. Not having to set up actions, action creators, and reducers make it easy to use, read, and maintain. The number of files also diminishes. Context also does not require any external packages to work. It comes with React and is part of its toolkit.

Where Redux shines

The main advantage of Redux over Context is its optimization. In the Context approach, even the smallest of changes to the store could cause the component that is using it to re-render if used improperly. Redux was designed to make use of observables, therefore it is possible to subscribe to a small portion of the store and only listen to necessary changes through mapStateToProps. This is also possible to achieve with regular Context, but it would require the programmer to be aware of performance pitfalls at all times. For the most part, splitting the provider into a separate component that only accepts children as a prop should suffice in preventing unnecessary renders in the Context approach.

Redux also comes with a time-travelling debugger, provides a middleware API, allowing for an even more intricate interaction with the store. This makes it easier to find potential bugs and fix them.

What would be best for you?

Global state solves issues mentioned in the section about the local component state. It prevents coupling between the component rendered elements and its state. No state has to be lifted as it already sits at the top of every component. The code is also more flexible allowing for easy movement of components. The problem with prop drilling also does not occur, and it is much easier to persist data between sessions when it is collected in one place.

Local state is ideal for simple state or in situations where not much UI changes would occur. It is also a preferable solution for small projects. These situations, however, can seldom be predicted. The main problem when setting a new state is deciding on its placement. It can either be put into the global scope, making the store more bloated or putting it in a local scope, making it less flexible. The main difference between Redux and Context is in the amount of boilerplate code that has to be written. Redux requires additional code in the form of actions, reducers, and support for additional packages. However, Context could lead to performance issues if one is not aware of the pitfalls.
 

Navigate the changing IT landscape

Some highlighted content that we want to draw attention to to link to our other resources. It usually contains a link .