DEV Community

Cover image for The Evolution of React State Management: From Local to Async
Caio Borghi
Caio Borghi

Posted on

The Evolution of React State Management: From Local to Async

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.

I don't know what it is
Local State is the state of a single Component.

Every time a state is updated, the component re-renders.


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>;
  }
}
}


Enter fullscreen mode Exit fullscreen mode

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>;
};


Enter fullscreen mode Exit fullscreen mode

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' });
};


Enter fullscreen mode Exit fullscreen mode

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
}


Enter fullscreen mode Exit fullscreen mode

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

Redux

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...

Tambor

Redux

Github Stars of React State Management Tools

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.

Disclaimer
"What? Does this make sense? Redux is to store data, not to fetch, how the F would you fetch data with Redux?"

If you thought about this, I must tell you:

I'm not actually fetching 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;
  }
};


Enter fullscreen mode Exit fullscreen mode

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>
  );
};


Enter fullscreen mode Exit fullscreen mode

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>;
}


Enter fullscreen mode Exit fullscreen mode

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')
);


Enter fullscreen mode Exit fullscreen mode

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

Zustand

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
Zustand Open Issues

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 });
    }
  },
}));


Enter fullscreen mode Exit fullscreen mode

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>
  );
}



Enter fullscreen mode Exit fullscreen mode

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> 
    ); 
};


Enter fullscreen mode Exit fullscreen mode

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.
Chef Kiss Emoji

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:
Fetching data flow

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.

Github Stars of React Async State Tools

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)