fullstackpilot Logo
Reactreact global stateglobal statestate management

React global state. How to manage a global state without Redux?

Martin Milo
By on

React global state is a state that is shared across all components in your React app. A global state can be accessed anywhere in your app without prop drilling, which refers to the process of sending props from a higher-level component to a lower-level component.

Table of Contents

State management can be tricky. No wonder there are so many state management libraries that try to make things easier. Yet they are often overused for simple use cases.

Local state in React

Simply put, local state is a state that is scoped to specific components. Doesn't make sense? Let's check out a code example:

// ./components/UserForm.jsx
import React from 'react'
export default function UserForm() {
const [name, setName] = React.useState('')
const handleSubmit = () => {}
return (
<div>
<input value={name} onChange={e => setName(e.target.value)} />
<button onClick={handleSubmit}>Submit form</button>
</div>
)
}

We declared a local state to hold an input value in the example above. Every time the user types in, the state is updated according to the provided input.

The name state is very specific to our UserForm component. Even if we would render more components in here and pass name to them, it's still going to be considered local. It's within a scope of certain components, not accessible outside.

// ./pages/MembersPage.jsx
import React from 'react'
import UserForm from 'components/UserForm'
export default function MembersPage() {
const [members, setMembers] = React.useState([])
// We fetch the members from the server by calling an endpoint
// Let's assume each member's data structure looks like this { id: 1, name: 'Joe' }
return (
<div>
<div>
{members.map(member => <div key={member.id}>{member.name}</div>)}
</div>
<UserForm />
</div>
)
}

Here we have a component called MembersPage. As you can see, we map over members that we got from the server, and below them, we render our UserForm component.

Since the UserForm is children of MembersPage, we can't access the name state in MembersPage. The state is effectively encapsulated in the UserForm. You're happy as a developer because the code is clean and well separated.

Okay, now the customer requests a feature that shows a new user on MembersPage right after the form is submitted. It has to be instant. This is not far from the real use case.

Here is how does the user flow looks like:

  • User types in the name in UserForm and clicks on submit
  • MembersPage should instantly update the members and render a new user on top of the list, so it not only feels smooth (as if the creation was instant)
  • We display an error message if a POST request (triggered by form submission) fails.

Eh. Okay. We have to update the state that lives in the parent component. How to do that?

There are multiple ways to go about this (note that these are not all possibilities):

  • We can move the name state from UserForm to members.
  • We can pass down the setMembers callback to update the members state any time the handleSubmit in UserForm is executed (any time user clicks on the button)

The first solution breaks the encapsulation, and now UserForm only renders inputs and relies on an external component to handle its state in a specific way. Not an ideal way to go.

The second one seems good enough. It adds a callback as a dependency, but we can call it onSuccess, and therefore it would be clear when this should be executed.

After all that hard work to keep things clean, a new design is proposed where we start showing success and error messages as snackbar or toast. It's just a different name for the same thing, but if none of this rings any bell for you, it's a box with a fixed position, usually on the right side of the screen with a message.

// ./app.jsx
import React from 'react'
export default function App() {
const [messages, setMessages] = React.useState([])
return (
<>
{/* Pages rendered here depending on a route */}
<Snackbars messages={messages} />
</>
)
}

As you can see, the component rendering Snackbar is placed in the root level, in the App component. Which means we don't have access to the state below the tree.

Do you start to see the problem?

We can pass callbacks again to update the messages state depending on success/error in the UserForm. Still, even in our simplistic example, we already have to pass multiple callbacks through multiple components.

In a more realistic scenario, the tree of components might be much deeper due to abstractions and various layouts. You could end up passing callbacks through 5 or more components any time a parent needs to know about children component's state.

Plus, these examples were a bit unique in terms of state flow. We had a name state (input) for a new user from which the members state was updated, and the process of doing so was either success/failure captured by messages state.

What if the name state was not used for artificially adding a new member but rather just displaying it in a preview outside of the form? Would we create another state and pass callback? Not really.

// ./pages/MembersPage.jsx
// Snipped of what is being rendered:
<>
<div>
{members.map(member => <div key={member.id}>{member.name}</div>)}
</div>
<div>
<span>Preview of new user:</span>
<div>{name}</div> {/* We don't have access to this state in this component */}
</div>
<UserForm />
</>

We're only dealing with one variable, and it's already becoming cumbersome. So...

Global state for the rescue. Or not.

We can use the global state as a container to store the user we're creating. We won't have to pass any callbacks, nor think about where to declare the state because the state would be accessible everywhere.

How to implement a global state?

We can use Redux, one of the most popular state management libraries for React apps. Another way would be to use Context, native to React, and not introduce any external dependencies.

Global state in React app with Redux

Long story short, the idea of Redux is to have a so-called store which is a central place where we put our data that need to be globally accessible.

The store is considered a single truth source where all changes happen and are transparent.

A store can be consumed by any component in the whole app since it usually sits at the top. Each component down the tree also has access to the callbacks (or actions) to update the store state.

With such a change in place, we don't really need a local state for each component down the tree.

Sounds great! What's the catch?

Redux introduces new concepts, dependencies, and a lot of boilerplate code. You now have to deal with actions (and action creators), reducers, and configuring the store itself.

Once you start dispatching actions that deal with async requests, you have to install additional packages, not to mention packages for testing and mocking all of this.

The purpose of Redux is to again help you manage the state. But the line between client state (synchronous) and server state (asynchronous) is often dismissed. Some people may put all the state in global stores, which seems like an abuse of the library. Just because you can, does not mean you should.

Redux, or its direct alternatives, are not ideal for:

  • Managing server state (asynchronous) - This might trigger many people, but I was using Redux extensively for years and found out that there are far better ways to deal with server state, such as React Query or SWR. Be sure to check them out and try them before screaming out loud.
  • Managing all client state - Theme or dark mode, some user preferences that impact the whole app? Sure. But there's no need to put every single piece of state in the global store. I would even say that perhaps most of what you may have in store is only relevant for a specific screen or set of components; thus, it's local, and there's no need for your whole app needs to keep track of that state.

I want to stress out that Redux is great and has its use cases, but it often seems to be overused. After being a long-term Redux user, I think it can be very useful for heavy interactions and client state apps.

Global state in React app with Context

Just like Redux, Context allows us to share data across the whole component tree without passing any props, but there are some differences.

First and foremost, there is no concept of the store as a single source of truth. You can create as many stores as you want with Context API. Each can be very specific to a certain set of components or app-wide (global).

Let's define a new Context for storing messages to render success/failure text to give user feedback after submitting form. Aside from just defining the Context itself, we also create a wrapper called MessagesProvider that wraps children with Context Provider to which we pass messages state and callbacks to update this state.

// ./providers/MessagesProvider.jsx
import React from 'react'
export const MessagesContext = React.createContext()
export default function MessagesProvider({ children }) {
const [messages, setMessages] = React.useState([])
return (
<MessagesContext.Provider value={{
messages,
onSuccess: () => setMessages(
messages.concat([{ type: 'success', text: 'Action succeeded!' }])
)
onFailure: () => setMessages(
messages.concat([{ type: 'failure', text: 'Action failed!' }])
)}}
>
{children}
</MessagesContext.Provider>
)
}

Now we can import it in our App component and wrap all children in MessagesContext Provider.

// ./app.js
import React from 'react'
import MessagesContextProvider from './providers/MessagesContext'
export default function App() {
return (
<MessagesContextProvider>
{/* Pages rendered here depending on route */}
<Snackbars />
</MessagesContextProvider>
)
}

As you noticed, we don't pass any props to snackbars, and all logic related to managing messages state is encapsulated in MessagesContextProvider. So how to access this state?

Let's go back to our UserForm component to demonstrate that.

// ./components/UserForm
import React from 'react'
import axios from 'axios'
import { MessagesContext } from '../providers/MesssagesContext'
export default function UserForm() {
const [name, setName] = React.useState('')
const { onSuccess, onFailure } = React.useContext(MessagesContext)
const handleSubmit = () => {
return axios.post('/users', { name })
.then(() => {
setSuccess()
})
.catch(() => {
setFailure()
})
}
return (
<div>
<input value={name} onChange={e => setName(e.target.value)} />
<button onClick={handleSubmit}>Submit form</button>
</div>
)
}

We accessed two callbacks that update the messages state in the provider whenever they are called. In the Snackbar component, we would call useContext the same way and accessed messages like this const { messages } = React.useContext(MessagesContext)

This way, we prevented prop drilling, and at the same time, we did not introduce any extra dependency. We can create more contexts that could be rather local than global, i.e., they would be scoped to certain screens or even components.

The benefit of doing so is that any time you change a state that is bound to a specific Context, it is clear where this change happens, and it does not touch one global store in which many unrelated actions may be dispatched.

Conclusion

Context API allows you to manage the global state in a React app without Redux or its direct alternatives. It has a different philosophy, is very flexible, and is native to React.

Remember that not all client states should end up either in a local store (created by Context for specific screen/components) or a global store. It is good to keep a state local if none of the other components have anything to do with it.

Share: