Table of Contents
Introduction
Hi!
This article presents an overview of how State was managed in React Applications thousands of years ago when Class Components dominated the world and functional components were just a bold idea, until recent times, when a new paradigm of State has emerged: Async State.
Local State
Alright, everyone who has already worked with React knows what a Local State is.
Every time a state is updated, the component re-renders.I don't know what it is
Local State is the state of a single Component.
You may have worked with this ancient structure:
class CommitList extends React.Component {
constructor(props) {
super(props);
this.state = {
isLoading: false,
commits: [],
error: null
};
}
componentDidMount() {
this.fetchCommits();
}
fetchCommits = async () => {
this.setState({ isLoading: true });
try {
const response = await fetch('https://api.github.com/repos/facebook/react/commits');
const data = await response.json();
this.setState({ commits: data, isLoading: false });
} catch (error) {
this.setState({ error: error.message, isLoading: false });
}
};
render() {
const { isLoading, commits, error } = this.state;
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<div>
<h2>Commit List</h2>
<ul>
{commits.map(commit => (
<li key={commit.sha}>{commit.commit.message}</li>
))}
</ul>
<TotalCommitsCount count={commits.length} />
</div>
);
}
}
class TotalCommitsCount extends Component {
render() {
return <div>Total commits: {this.props.count}</div>;
}
}
}
Perhaps a modern functional one:
const CommitList = () => {
const [isLoading, setIsLoading] = useState(false);
const [commits, setCommits] = useState([]);
const [error, setError] = useState(null);
// To update state you can use setIsLoading, setCommits or setUsername.
// As each function will overwrite only the state bound to it.
// NOTE: It will still cause a full-component re-render
useEffect(() => {
const fetchCommits = async () => {
setIsLoading(true);
try {
const response = await fetch('https://api.github.com/repos/facebook/react/commits');
const data = await response.json();
setCommits(data);
setIsLoading(false);
} catch (error) {
setError(error.message);
setIsLoading(false);
}
};
fetchCommits();
}, []);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<div>
<h2>Commit List</h2>
<ul>
{commits.map(commit => (
<li key={commit.sha}>{commit.commit.message}</li>
))}
</ul>
<TotalCommitsCount count={commits.length} />
</div>
);
};
const TotalCommitsCount = ({ count }) => {
return <div>Total commits: {count}</div>;
};
Or even a "more accepted" one? (Definitely more rare though)
const initialState = {
isLoading: false,
commits: [],
userName: ''
};
const reducer = (state, action) => {
switch (action.type) {
case 'SET_LOADING':
return { ...state, isLoading: action.payload };
case 'SET_COMMITS':
return { ...state, commits: action.payload };
case 'SET_USERNAME':
return { ...state, userName: action.payload };
default:
return state;
}
};
const CommitList = () => {
const [state, dispatch] = useReducer(reducer, initialState);
const { isLoading, commits, userName } = state;
// To update state, use dispatch. For example:
// dispatch({ type: 'SET_LOADING', payload: true });
// dispatch({ type: 'SET_COMMITS', payload: [...] });
// dispatch({ type: 'SET_USERNAME', payload: 'newUsername' });
};
Which can make you wonder...
Why the hack would I be writing this complex reducer for a single component?
Well, React inherited this ugly hook called useReducer
from a very important tool called Redux.
If you ever had to deal with Global State Management in React, you must've heard about Redux.
This brings us to the next topic: Global State Management.
Global State
Global State Management is one of the first complex subjects when learning React.
What is it?
It can be multiple things, built in many ways, with different libraries.
I like to define it as:
A single JSON object, accessed and maintained by any Component of the application.
const globalState = {
isUnique: true,
isAccessible: true,
isModifiable: true,
isFEOnly: true
}
I like to think of it as:
A Front-End No-SQL Database.
That's right, a Database. It's where you store application data, that your components can read/write/update/delete.
I know, by default, the state will be recreated whenever the user reloads the page, but that may not be what you want it to do, and if you're persisting data somewhere (like the localStorage), you might want to learn about migrations
to avoid breaking the app every new deployment.
I like to use it as:
A multidimensional portal, where components can dispatch their feelings and select their attributes. Everything, everywhere, all at once.
How to use it?
The main way
It is the industry standard.
I have worked with React, TypeScript, and Redux for 7 years. Every project I've worked with professionally uses Redux.
The vast majority of people I've met who works with React, use Redux.
The most mentioned tool in React open positions at Trampar de Casa is Redux.
The most popular React State Management tool is...
Redux
If you want to work with React, you should learn Redux.
If you currently work with React, you probably already know.
Ok, here's how we usually fetch data using Redux.
If you thought about this, I must tell you: I'm not actually fetching data with Redux. Disclaimer
"What? Does this make sense? Redux is to store data, not to fetch, how the F would you fetch data with Redux?"
Redux will be the cabinet for the application, it'll store ~shoes~ states that are directly related to fetching, that's why I used this wrong phrase: "fetch data using Redux".
// actions
export const SET_LOADING = 'SET_LOADING';
export const setLoading = (isLoading) => ({
type: SET_LOADING,
payload: isLoading,
});
export const SET_ERROR = 'SET_ERROR';
export const setError = (isError) => ({
type: SET_ERROR,
payload: isError,
});
export const SET_COMMITS = 'SET_COMMITS';
export const setCommits = (commits) => ({
type: SET_COMMITS,
payload: commits,
});
// To be able to use ASYNC action, it's required to use redux-thunk as a middleware
export const fetchCommits = () => async (dispatch) => {
dispatch(setLoading(true));
try {
const response = await fetch('https://api.github.com/repos/facebook/react/commits');
const data = await response.json();
dispatch(setCommits(data));
dispatch(setError(false));
} catch (error) {
dispatch(setError(true));
} finally {
dispatch(setLoading(false));
}
};
// the state shared between 2-to-many components
const initialState = {
isLoading: false,
isError: false,
commits: [],
};
// reducer
export const rootReducer = (state = initialState, action) => {
// This could also be actions[action.type].
switch (action.type) {
case SET_LOADING:
return { ...state, isLoading: action.payload };
case SET_ERROR:
return { ...state, isError: action.payload };
case SET_COMMITS:
return { ...state, commits: action.payload };
default:
return state;
}
};
Now on the UI side, we integrate with actions using useDispatch and useSelector:
// Commits.tsx
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { fetchCommits } from './action';
export const Commits = () => {
const dispatch = useDispatch();
const { isLoading, isError, commits } = useSelector(state => state);
useEffect(() => {
dispatch(fetchCommits());
}, [dispatch]);
if (isLoading) return <div>Loading...</div>;
if (isError) return <div>Error while trying to fetch commits.</div>;
return (
<ul>
{commits.map(commit => (
<li key={commit.sha}>{commit.commit.message}</li>
))}
</ul>
);
};
If Commits.tsx
was the only component that needed to access commits
list, you shouldn't store this data on the Global State. It could use the local state instead.
But let's suppose you have other components that need to interact with this list, one of them may be as simple as this one:
// TotalCommitsCount.tsx
import React from 'react';
import { useSelector } from 'react-redux';
export const TotalCommitsCount = () => {
const commitCount = useSelector(state => state.commits.length);
return <div>Total commits: {commitCount}</div>;
}
Disclaimer
In theory, this piece of code would make more sense living inside Commits.tsx
, but let's assume we want to display this component in multiple places of the app and it makes sense to put the commits
list on the Global State and to have this TotalCommitsCount
component.
With the index.js component being something like this:
import React from 'react';
import ReactDOM from 'react-dom';
import thunk from 'redux-thunk';
import { createStore, applyMiddleware } from 'redux';
import { Provider } from 'react-redux';
import { Commits } from "./Commits"
import { TotalCommitsCount } from "./TotalCommitsCount"
export const App = () => (
<main>
<TotalCommitsCount />
<Commits />
</main>
)
const store = createStore(rootReducer, applyMiddleware(thunk));
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
);
This works, but man, that looks overly complicated for something as simple as fetching data right?
Redux feels a little too bloated to me.
You're forced to create actions and reducers, often also need to create a string name for the action to be used inside the reducer, and depending on the folder structure of the project, each layer could be in a different file.
Which is not productive.
But wait, there is a simpler way.
The simple way
At the time I'm writing this article, Zustand has 3,495,826 million weekly downloads, more than 45,000 stars on GitHub, and 2, that's right, TWO open Pull Requests.
ONE OF THEM IS ABOUT UPDATING IT'S DOC
If this is not a piece of Software Programming art, I don't know what it is.
Here's how to replicate the previous code using Zustand.
// store.js
import create from 'zustand';
const useStore = create((set) => ({
isLoading: false,
isError: false,
commits: [],
fetchCommits: async () => {
set({ isLoading: true });
try {
const response = await fetch('https://api.github.com/repos/facebook/react/commits');
const data = await response.json();
set({ commits: data, isError: false });
} catch (error) {
set({ isError: true });
} finally {
set({ isLoading: false });
}
},
}));
This was our Store, now the UI.
// Commits.tsx
import React, { useEffect } from 'react';
import useStore from './store';
export const Commits = () => {
const { isLoading, isError, commits, fetchCommits } = useStore();
useEffect(() => {
fetchCommits();
}, [fetchCommits]);
if (isLoading) return <div>Loading...</div>;
if (isError) return <div>Error occurred</div>;
return (
<ul>
{commits.map(commit => (
<li key={commit.sha}>{commit.commit.message}</li>
))}
</ul>
);
}
And last but not least.
// TotalCommitsCount.tsx
import React from 'react';
import useStore from './store';
const TotalCommitsCount = () => {
const totalCommits = useStore(state => state.commits.length);
return (
<div>
<h2>Total Commits:</h2> <p>{totalCommits}</p>
</div>
);
};
There are no actions and reducers, there is a Store
.
And it's advisable to have slices
of Store
, so everything is near to the feature related to the data.
It works perfect with a folder-by-feature
folder structure.
The wrong way
I need to confess something, both of my previous examples are wrong.
And let me do a quick disclaimer: They're not wrong, they're outdated, and therefore, wrong.
This wasn't always wrong though. That's how we used to develop data fetching in React applications a while ago, and you may still find code similar to this one out there in the world.
But there is another way.
An easier one, and more aligned with an essential feature for web development: Caching. But I'll get back to this subject later.
Currently, to fetch data in a single component, the following flow is required:
What happens if I need to fetch data from 20 endpoints inside 20 components?
- 20x isLoading + 20x isError + 20x actions to mutate this properties.
What will they look like?
With 20 endpoints, this will become a very repetitive process and will cause a good amount of duplicated code.
What if you need to implement a caching feature to prevent recalling the same endpoint in a short period? (or any other condition)
Well, that will translate into a lot of work for basic features (like caching) and well-written components that are prepared for loading/error states.
This is why Async State was born.
Async State
Before talking about Async State I want to mention something. We know how to use Local and Global state but at this time I didn't mention what should be stored and why.
The Global State example has a flaw and an important one.
The TotalCommitsCount
component will always display the Commits Count, even if it's loading or has an error.
If the request failed, there's no way to know that the Total Commits Count is 0, so presenting this value is presenting a lie.
In fact, until the request finishes, there is no way to know for sure what's the Total Commits Count value.
This is because the Total Commits Count is not a value we have inside the application. It's external information, async stuff, you know.
We shouldn't be telling lies if we don't know the truth.
That's why we must identify Async State in our application and create components prepared for it.
We can do this with React-Query, SWR, Redux Toolkit Query and many others.
For this article, I'll use React-Query.
I recommend you to access the docs of each of these tools to better understand which problems they solve.
Here's the code:
No more actions, no more dispatches, no more Global State for fetching data.
This is what you have to do in your App.tsx
file to have React-Query properly configured:
You see, Async State is special.
It's like Schrödinger's cat – you don't know the state until you observe it (or run it).
But wait, if both components are calling useCommits
and useCommits
is calling an API endpoint
, does this mean that there will be TWO identical requests to load the same data?
Short Answer: no!
Long Answer: React Query is awesome. It automatically handles this situation for you, it comes with pre-configured caching that is smart enough to know when to refetch your data or simply use the cache.
It's also extremely configurable so you can tweak to fit 100% of your application's needs.
Now we have our components always ready for isLoading
or isError
and we keep the Global State
less polluted and have some pretty neat features out-of-the-box.
Conclusion
Now you know the difference between Local, Global and Async State.
Local -> Component Only.
Global -> Single-Json-NoSQL-DB-For-The-FE.
Async -> External data, Schrodinger's cat-like, living outside of the FE application that requires Loading
and can return Error
.
I hope you enjoyed this article, let me know if you have different opinions or any constructive feedback, cheers!
Top comments (0)