Since the initial release of React in 2013, Facebook has rolled out a robust collection of tools to help developers with some of the minutiae of the web application development process, allowing them to focus on what matters most.
Despite React’s many capabilities and widespread adoption among developers, I’ve discovered that many of us face the same question: How can we handle complicated states with React?
You may like: Crud Using React Hooks and Context API
This post will look at what state is, how to arrange it, and alternative patterns to use as our applications become more complicated.
Understanding React State
React may be thought of as a blueprint in its most basic form. Your application will appear in a specific way depending on the status. React prefers declarative over imperative programming, a fancy way of stating that you express what you want to happen rather than the processes to get there. As a result, appropriately managing the state becomes critical, as the state determines how your program will act.
React State in action
Before we get started, it’s a good idea to define what a state is. State, in my opinion, is a set of changeable values that change over time and directly impact component behavior.
Props and state are extremely similar, except state may be updated inside the context of where it is created, whereas props received cannot be edited without supplying a callback function.
Take a look at this:
const UserList = () => {
const [users, setUsers] = useState([])
useEffect(() => {
const getUsers = async () => {
const response = await fetch("https://myuserapi.com/users")
const users = await response.json()
setUsers(users)
}
getUsers()
}, [])
if (users.length < 1) return null;
return <ul>
{users.map(user => <li>{user.name}</li>)}
</ul>
}
When the component mounts, we fetch users from an API and update the users’ array after receiving a response. To keep the example simple, we use the naïve assumption that the call will always be successful.
The state is being used to render list items with the user’s name, and it will return null if the array contains no users. The state varies over time and is used to control component behavior directly.
It’s also worth noting that we’re using React’s built-in state management technique, the useState
Hook. You may need to utilize React’s built-in Hook to handle your state, depending on the intricacy of your application and state management.
The built-in state management mechanism isn’t always enough, as seen by the number of state management alternatives for React. It’s time to look at some of the reasons for this.
Understanding prop drilling
Let’s have a look at a little more complicated App. As your project grows, you’ll be pushed to add many levels of components to segregate concerns and/or improve readability. The issue arises when the state is required by various components located at various levels of the tree.
If we wanted to provide user data to both the UserMenu
and Profile
components, we had to put the state in App
where it could be propagated down to each component that needed it. That means we’ll feed it via components like Dashboard and Settings that don’t need it, contaminating them with needless data.
You’ll need to pass the updater function (the setUsers
method from the previous example) to the component that needs to execute the updating, as well as another property to propagate down – all of this for one piece of state. Consider exacerbating the problem by adding five additional characteristics. It may swiftly spiral out of control.
That signifies how comfortable I am digging attributes and updater functions across numerous levels of components for me. I have a three-layer limit; beyond that, I look for another option. However, until then, I’m insistent about using React’s built-in capabilities.
State libraries are also expensive, and there’s no need to add excessive complexity unless it’s essential.
The re-rendering issue
The internal state management might become troublesome as the application expands since React immediately prompts a re-render when the state is modified. Different branches of the component tree may require the same data, and the only method to supply this data to these components is to raise the state to the nearest common ancestor.
When the application expands, more state will need to be moved up the component tree, increasing the amount of prop drilling and causing wasteful re-renders as the state changes.
The testing issue
Another issue with putting all of your state in the components is that it becomes difficult to test your state handling. Stateful components need the creation of complicated test scenarios in which activities that activate state are invoked, and the outcome is matched. Testing state in this manner may soon get complicated, and altering how state works in your application will almost always need a complete rewrite of your component tests.
Managing REACT state with Redux
When it comes to state libraries, Redux is one of the most well-known and commonly used libraries for state management. Redux is a state container that was introduced in 2015 that allows you to construct a manageable and tested state. It is built on the ideas of Flux, a Facebook open-source architecture paradigm.
In essence, Redux offers a global state object that gives each component with the state it requires, with only the components that get the state requiring re-rendering (and their children). Redux is a state management system that is built on actions and reducers. Let’s take a brief look at the components:
The component transmits action to the reducer in this example. The reducer modifies the state, resulting in a re-render.
State
State is the sole source of truth; it always represents your current state. Its duty is to provide state to the components. Consider the following scenario:
{
users: [{ id: "1231", username: "Dale" }, { id: "1235", username: "Sarah"}]
}
Actions
Predefined objects that reflect a change in the state are known as actions. They are simple text objects that adhere to a set of rules:
{
type: "ADD_USER",
payload: { user: { id: "5123", username: "Kyle" } }
}
Reducers
State is the sole source of truth; it always represents your current state. Its duty is to provide state to the components. Consider the following scenario:
const userReducer = (state, action) => {
switch (action.type) {
case "ADD_USER":
return { ...state, users: [...state.users, action.payload.user ]}
default:
return state;
}
}
Contemporary React state patterns
While Redux is still a fantastic tool, React has matured over time and provided us with new technologies. In addition, new concepts and thinking have been incorporated into state administration, resulting in a variety of state management approaches. In this part, we’ll look at some more recent patterns.
the Context API
Hooks were introduced in React 16.8 and provided us with new ways to share functionality throughout our application. Consequently, we now have access to useReducer
, a built-in React Hook that allows us to construct reducers right out of the box. When we combine this feature with React’s Context API, we have a lightweight Redux-like solution that we can utilize in our project.
Let’s look at an example of a reducer that handles API calls:
const apiReducer = (state = {}, action) => {
switch (action.type) {
case "START_FETCH_USERS":
return {
...state,
users: { success: false, loading: true, error: false, data: [] }
}
case "FETCH_USERS_SUCCESS":
return {
...state,
users: { success: true, loading: true, error: false, data: action.payload.data}
}
case "FETCH_USERS_ERROR":
return {
...state,
users: { success: false, loading: false, error: true, data: [] }
}
case default:
return state
}
}
Let’s make our context now that we’ve got our reducer:
const apiContext = createContext({});
export default apiContext;
Let’s combine these two elements with building a very flexible state management system:
import apiReducer from './apiReducer';
import ApiContext from './ApiContext';
const initialState = { users: { success: false, loading: false, error: false, data: []}};
const ApiProvider = ({ children }) => {
const [state, dispatch] = useReducer(apiReducer, initialState)
return <ApiContext.Provider value={{ ...state, apiDispatcher: dispatch }}>
{children}
</ApiContext.Provider>
}
Now that we’ve done that, we need to wrap this provider around the components in our App that require access to this state. For instance, consider the following at the heart of our application:
ReactDOM.render(document.getElementById("root"),
<ApiProvider>
<App />
</ApiProvider>
)
Any component that is a child of App
may now use our ApiProviders
state and dispatcher to trigger operations and access the state as follows:
import React, { useEffect } from 'react';
import ApiContext from '../ApiProvider/ApiContext';
const UserList = () => {
const { users, apiDispatcher } = useContext(ApiContext);
useEffect(() => {
const fetchUsers = () => {
apiDispatcher({ type: "START_FETCH_USERS" })
fetch("https://myapi.com/users")
.then(res => res.json())
.then(data => apiDispatcher({ type: "FETCH_USERS_SUCCCESS", users: data.users }))
.catch((err) => apiDispatcher({ type: "START_FETCH_ERROR" }))
}
fetchUsers();
}, [])
const renderUserList = () => {
// ...render the list
}
const { loading, error, data } = users;
return <div>
<ConditionallyRender condition={loading} show={loader} />
<ConditionallyRender condition={error} show={loader} />
<ConditonallyRender condition={users.length > 0} show={renderUserList} />
<div/>
}
Managing state with state machines and XState
State machines are another prominent method of handling state. State machines, to put it another way, are dedicated state containers that can retain a finite number of states at any given moment. State machines become exceedingly predictable as a result of this. You may enter a state machine into a generator and get a state chart with an overview of your data flow since each state machine follows the same pattern.
State machines are inspired by Redux, but they adhere to stricter standards in terms of the format in order to ensure predictability. XState is the most popular library for generating, reading, and dealing with state machines in the realm of React state management.
Let’s look at an example from the XState documentation:
import { createMachine, interpret, assign } from 'xstate';
const fetchMachine = createMachine({
id: 'Dog API',
initial: 'idle',
context: {
dog: null
},
states: {
idle: {
on: {
FETCH: 'loading'
}
},
loading: {
invoke: {
id: 'fetchDog',
src: (context, event) =>
fetch('https://dog.ceo/api/breeds/image/random').then((data) =>
data.json()
),
onDone: {
target: 'resolved',
actions: assign({
dog: (_, event) => event.data
})
},
onError: 'rejected'
},
on: {
CANCEL: 'idle'
}
},
resolved: {
type: 'final'
},
rejected: {
on: {
FETCH: 'loading'
}
}
}
});
const dogService = interpret(fetchMachine)
.onTransition((state) => console.log(state.value))
.start();
dogService.send('FETCH');
useSWR
State management has become more complicated over time. Nevertheless, we can accomplish wonderful things with proper state management, and there’s no doubt that we’re transferring a lot of complexity to the front end. More cognitive strain, indirection, the possibility for problems, and code that has to be fully tested are all things we’re embracing.
In this aspect, useSWR
has been a breath of fresh air. When you combine this library with React Hooks’ natural features, you get a degree of simplicity that’s hard not to like. This library employs the stale-while-revalidate HTTP cache approach, which means it preserves a local cache of the previous dataset and syncs with the API in the background to obtain new data.
Because the UI may react with stale data while waiting for changes to be retrieved, the program remains extremely performant and user-friendly. Let’s see how we may make use of this library to eliminate some of the difficulties of state management.
// Data fetching hook
import useSWR from 'swr';
const useUser(userId) {
const fetcher = (...args) => fetch(...args).then(res => res.json())
const { data, error } = useSWR(`/api/user/${userId}`, fetcher)
return {
user: data,
error,
loading: !data && !error
}
}
export default useUser;
We now have a reusable Hook that we can use to populate our component views with data. To retrieve your data, you don’t need to develop reducers, actions, or link components to state – import and utilize the Hook in the components that require it:
import Loader from '../components/Loader';
import UserError from '../components/UserError';
import useUser from '../hooks/useUser';
const UserProfile = ({ id }) => {
const { user, error, loading } = useUser(id);
if (loading) return <Loader />
if (error) return <UserError />
return <div>
<h1>{user.name}</h1>
...
</div>
}
And in another component:
import Loader from '../components/Loader'
import UserError from '../components/UserError'
import useUser from '../hooks/useUser';
const Header = ({ id }) => {
const { user, error, loading } = useUser(id);
if (loading) return <Loader />
if (error) return <UserError />
return <div>
<Avatar img={user.imageUrl} />
...
</div>
}
Because the first input to useSWR
is a key, you can send Hooks that can access a shared data object around.
const { data, error } = useSWR(`/api/user/${userId}`, fetcher);
Our requests are deduped, cached, and shared across all of our components that useUser
Hook based on this key. This also implies that if the key matches, just one request is performed to the API. Therefore, even if we have ten components that utilize the useUser
Hook, only one request will be issued if the useSWR
key is the same.
Conclusion
If React is a canvas that depicts your application’s state at any given time, then state is critical to get properly. We’ve looked at a few different ways to handle state in React applications in this post, and there are a lot more thoughts we could have covered.
Recoil and Jotai, as well as React Query and MobX, are all important in this topic but so many distinct state libraries are fantastic. It encourages us to try new ideas and encourages library authors to keep improving. And it is the path to take.
Which option should you pick for your project now?