Hello, fellow web perf enthusiast! Contribute to the 2022 edition happening this month. Click.

Web Performance Calendar

The speed geek's favorite time of year
2021 Edition
ABOUT THE AUTHOR
Krasimir Tsonev photo

Krasimir Tsonev (@KrasimirTsonev) is a coder with over 15 years of experience in web development. Author of books on JavaScript, he works at Antidote.me where he helps people reaching clinical trials. Loves React and its ecosystem.


Building UI has always been a challenge. One of the main problems to solve is state management. React applications are not an exception. When Facebook announced the library, we considered it a view layer. Quickly after that, we realized that it’s more than that. With its one-direction data flow, React made the front-end development a bit more predictable and robust. However, the answer to an important question was missing – “How do we manage state?”. Over the years, Facebook and the React community came with different types of solutions. In this article, we will see some of them and will give tips on when to use them.

The first step to solving the problem is to identify the problem

State management is hard and becomes even harder if we are doing it in the wrong place. The first step is to identify what kind of state we are dealing with. There are different types, and ironing out which one we have is critical for the tooling after that. If we make a misjudgment at this point, we will end up with a setup that is not optimal. So, answering the “What lives where?” question is important.

Local state

A state that lives only in a single component we consider local. An example of that is the view-more functionality. Imagine we have a long text, but we truncate it and show a small link at the end. If the user clicks it, we will reveal the content. We need a flag that tells us whether the link is pressed or not.

function Text() {
  const [viewMore, setViewMore] = useState(false);

  return (
    <Fragment>
      <p>
        React makes it painless to create interactive UIs. 
        {
          viewMore && <span>
            Design simple views for each state in your application.
          </span>
        }
      </p>
      <button onClick={() => setViewMore(true)}>read more</button>
    </Fragment>
  );
}

The usage of useState in this component is our state management. A technique to identify this type of state is to ask who needs to know about it. In this case, it’s just our <Text> component. So, it makes sense to create viewMore flag right on the place where we will consume the value.

The problems with the local state come very often when we try to manage too many things. We position the state correctly, but the logic requires multiple variables. Consider the following example:

function ILoveCats() {
  const [cat, setCatURL] = useState(null);
  const [isLoading, setLoadingFlag] = useState(false);
  const [error, setError] = useState(null);

  async function getCat() {
    setLoadingFlag(true);
    try {
      const query = await fetch('https://api.thecatapi.com/v1/images/search');
      const [{ url }] = await query.json();
      setLoadingFlag(false);
      setCatURL(url);
    } catch(error) {
      setError(true)
    }
  }
  return (
    <>
      <button onClick={getCat}>I love cats</button>
      { error && 'Meow! Meow!'}
      { isLoading && 'Loading ...'}
      { cat && <img src={cat} /> }
    </>
  )
}

The <ILoveCats> component offers a feature that shows a picture of a cat on the screen. The user needs to press a button, and the getCat function delivers the picture’s URL. Notice how we have three variables to manage the whole process. We have one that keeps the URL, another that tells us if the request is in progress, and one for errors. We can think of some other edge cases, and we will probably bring more useState statements. One of the possible solutions, in this case, is to look at a state machine.

const STATES = {
  IDLE: 'IDLE',
  LOADING: 'LOADING',
  SUCCESS: 'SUCCESS',
  ERROR: 'ERROR'
}
function ILoveCats() {
  const [status, setStatus] = useState({ value: STATES.IDLE });

  async function getCat() {
    setStatus({ value: STATES.LOADING });
    try {
      const query = await fetch('https://api.thecatapi.com/v1/images/search');
      const [{ url }] = await query.json();
      setStatus({ value: STATES.SUCCESS, url });
    } catch(error) {
      setStatus({ value: STATES.ERROR });
    }
  }
  return (
    <>
      <button onClick={getCat}>I love cats</button>
      { status.value === STATES.ERROR && 'Meow! Meow!'}
      { status.value === STATES.LOADING  && 'Loading ...'}
      { status.value === STATES.SUCCESS  && <img src={status.url} /> }
    </>
  )
}

With this new component version, we have just one variable – status. It keeps the current progress of the user. We can talk a lot about state machines and their benefits. If you want to explore the concept make sure to check out XState. We have to clarify that we didn’t implement a complete state machine. We don’t have the transition definitions. But even without that, the component reads better, and it becomes easier to follow the logic.

The next type of state that we will talk about combines multiple components.

Feature state

Feature state is when we have two or more components sharing the same information. An example of such a state is every form that contains multiple inputs. Let’s illustrate with an example:

const Skill = ({ onChange }) => (
  <label> Skill:
    <input type="text" onChange={e => onChange(e.target.value)}/>
  </label>
);
const Years = ({ onChange }) => (
  <label> Years of experience:
    <input type="text" onChange={e => onChange(e.target.value)}/>
  </label>
);

function Form() {
  const [skill, setSkill] = useState('');
  const [years, setYears] = useState('');

  const isFormReady = skill !== '' && years !== '';

  return (
    <form>
      <Skill onChange={setSkill} />
      <Years onChange={setYears} />
      <button disabled={!isFormReady}>submit</button>
    </form>
  );
}

A form with two text inputs. The user types values in both fields. We disable the button by default and enable it only if we have two values present. Notice how we need skills and years close to each so we can calculate isFormReady. The <Form> component is a perfect place for implementing such logic because it wraps all the inputs. Often such components manage a data object containing all the form information. This example shows the thin line between the feature and application state (we will see it in the next section). We could easily put that state in a Redux store. We may write a selector isFormReady, create actions and a reducer. It will work just fine, but the question is whether that is the right place for such information. In most cases the answer is “no”.

Application state

An application state is a state that leads the overall experience of the user. It could be authorization status, profile data, or a global theme. Such state is potentially needed everywhere in the application. That’s why the form from the previous section doesn’t fall into this category. User inputs are usually scoped to specific functionality. The state that we need there dies as soon as the interaction ends. The application state is another piece of data that needs to be alive through the entire user journey. Here is a quick example:

const ThemeContext = React.createContext();
const Theme = () => {
  const { theme } = useContext(ThemeContext);
  return <div style={{
    background: theme === 'light' ? 'white' : 'grey'
  }}>Hello world</div>;
}
const ThemeSelector = () => {
  const { theme, toggleTheme } = useContext(ThemeContext);
  return (
    <select value={theme} onChange={toggleTheme}>
      <option value="light">light</option>
      <option value="dark">dark</option>
    </select>
  );
}

function App() {
  const [theme, setTheme] = useState('light');
  const toggleTheme = () => setTheme(theme === 'light' ? 'dark' : 'light');

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      <header>
        <Theme />
      </header>
      <footer>
        <ThemeSelector />
      </footer>
    </ThemeContext.Provider>
  );
}

Here we have a header and a footer. They both need to know the current theme of the app. The example is simple but imagine that Theme and ThemeSelector are components deeply nested into other components. They do not have direct access to the theme variable. They also use it for different things. Theme only shows the current value while ThemeSelector changes it.

We can recognize such a state quicker than the feature state. If something is used widely and needs updating from distant components, then we probably have to put it into the application state. This is also the most tempting action for most developers. We first consider using the feature or even local state. It will make our life easier.

Server state

In the end, there is one other type of state that doesn’t live in the browser. That’s when we use some persistent storage. Like a database. Last couple of years we are seeing more and more tools in that direction. GraphQL, for example, is sometimes used as storage for an application state. All the data lives outside the app, and instruments like Apollo deal with the data fetching and synchronization. ReactQuery is another example of that.

Implementation-wise, the management of the state is abstracted into a hook or a component. We use it and don’t think much about the lifecycle of the data. Here is an example that uses ReactQuery:

import { QueryClient, QueryClientProvider, useMutation, useQuery } from 'react-query'

const queryClient = new QueryClient()

function Example() {
  const { isLoading, error, data } = useQuery('my-data', loadData, { initialData: '' });
  const { mutate } = useMutation(saveData, {
    onSuccess: () => {
       queryClient.invalidateQueries('my-data')
     },
   });

  return (
    <div>
      <p>{data}</p>
      <input type="text" onChange={e => mutate(e.target.value) } defaultValue={data}/>
    </div>
  )
}
function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Example />
    </QueryClientProvider>
  )
}

ReactDOM.render(<App />, document.querySelector('#root'));

async function saveData(value) {
  return new Promise(done => {
    setTimeout(() => {
      localStorage.setItem('my-data', value);
      done();
    }, 1000);
  })
}
async function loadData() {
  return new Promise(done => {
    setTimeout(() => { done(localStorage.getItem('my-data')); }, 1000);
  });
}

saveData and loadData are here to simulate a database. We are wrapping the localStorage API in promises, which is synchronous, to make it look like it takes some time. We will pretend that we query a remote database. The <Example> component uses useQuery and useMutation to load and save data. There is a technique to implement optimistic updates which will make our demo snappy. It will look like we did it with `useState.

Of course, with this example, we are only scratching the surface. Libraries like ReactQuery can do a lot more. Data caching, data invalidation, error handling, fallback logic, and so on. All sorts of things that we usually do by ourselves.

Conclusion

The optimization of the state management starts with identifying what kind of state we have. It continues with picking the right tool for the job. Nowadays, React as a library offers many instruments for managing the state. The hooks and context APIs are good enough for the majority of cases. If they don’t fit then we have a variety of third-party tools at our disposal. Redux and Redux toolkit, for example, are still around. Libraries like ReactQuery step in trying to find their place in the ecosystem. We have lots of different options in terms of tooling. What we should remember at the end is that state management is more about figuring out the type of the state. It’s less about the tools we use.